Compare commits
2
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d9a38bb7fb | ||
|
|
3b24fe1b3c |
@@ -0,0 +1,3 @@
|
||||
[submodule "scripts/vendor/jray-project"]
|
||||
path = scripts/vendor/jray-project
|
||||
url = git@gitea.tourolle.paris:dtourolle/jray-project.git
|
||||
@@ -0,0 +1,110 @@
|
||||
namespace Jellyfin.Plugin.JRay.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// How far a configured manifest server is trusted. See the JRay public server
|
||||
/// specification, §9 "Trusting third-party servers".
|
||||
/// </summary>
|
||||
public enum ServerTrustLevel
|
||||
{
|
||||
/// <summary>
|
||||
/// Accept manifests, but never contribute to this server and never send
|
||||
/// library inventory beyond the single item being queried. The default for
|
||||
/// user-added servers.
|
||||
/// </summary>
|
||||
FetchOnly = 0,
|
||||
|
||||
/// <summary>
|
||||
/// Eligible to contribute to, subject to <see cref="ManifestServer.AllowContribute"/>.
|
||||
/// </summary>
|
||||
Full = 1,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The minimum cut-match tier a fetched manifest must reach before it is stored.
|
||||
/// See the public server specification, §3 "Cut matching".
|
||||
/// </summary>
|
||||
public enum MatchTier
|
||||
{
|
||||
/// <summary>Audio 0.60–0.85, or runtimes within ±30s. Surfaced as a caveat in the UI.</summary>
|
||||
Loose = 0,
|
||||
|
||||
/// <summary>Runtimes within ±2s.</summary>
|
||||
Runtime = 1,
|
||||
|
||||
/// <summary>Audio signature score ≥ 0.85; may carry a non-zero offset.</summary>
|
||||
Audio = 2,
|
||||
|
||||
/// <summary>Identical <c>video_hash</c> — the same file.</summary>
|
||||
Exact = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// One entry in the ordered list of manifest servers the plugin queries
|
||||
/// (public server specification, §9 "Multiple servers").
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The list is ordered because order *is* the user's trust ranking, made
|
||||
/// explicit: for a fetch, servers are tried in order and the first acceptable
|
||||
/// result wins. Querying every server for every item would multiply egress and
|
||||
/// leak the library to more parties.
|
||||
///
|
||||
/// <c>FetchOnly</c> is the default for user-added servers. Adding a third-party
|
||||
/// server means trusting its operator not to serve deliberately wrong actor
|
||||
/// data — the client-side controls bound the damage to bad overlay content,
|
||||
/// they cannot make wrong data right.
|
||||
/// </remarks>
|
||||
// TRACES: JR-025 | PR-005, PR-006
|
||||
public class ManifestServer
|
||||
{
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestServer"/> class.
|
||||
/// </summary>
|
||||
public ManifestServer()
|
||||
{
|
||||
Url = string.Empty;
|
||||
Name = string.Empty;
|
||||
Token = string.Empty;
|
||||
Enabled = false;
|
||||
AllowContribute = false;
|
||||
TrustLevel = ServerTrustLevel.FetchOnly;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the base URL of the server, e.g. "https://jray.tourolle.paris".
|
||||
/// HTTPS is required for non-loopback servers: a plaintext server would let
|
||||
/// any network intermediary rewrite actor overlays.
|
||||
/// </summary>
|
||||
public string Url { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the display label shown in the configuration page.
|
||||
/// </summary>
|
||||
public string Name { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the API token used to contribute manifests. Optional —
|
||||
/// required only to contribute, never to fetch. This is an anonymous bearer
|
||||
/// capability rather than an account (public server specification, §5a).
|
||||
/// </summary>
|
||||
public string Token { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether this server is queried at all.
|
||||
/// Lets an admin disable an entry without deleting it and losing its token.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether locally generated manifests may be
|
||||
/// contributed to this server. Independent of fetching, and off by default:
|
||||
/// contribution is never fanned out, because broadcasting uploads to every
|
||||
/// configured server would multiply privacy exposure without the user
|
||||
/// intending it.
|
||||
/// </summary>
|
||||
public bool AllowContribute { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets how far this server is trusted.
|
||||
/// </summary>
|
||||
public ServerTrustLevel TrustLevel { get; set; }
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Collections.ObjectModel;
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Configuration;
|
||||
@@ -5,8 +6,28 @@ namespace Jellyfin.Plugin.JRay.Configuration;
|
||||
/// <summary>
|
||||
/// Plugin configuration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every manifest-exchange switch here defaults to <b>off</b>, including the
|
||||
/// pre-configured community server, so no traffic leaves an installation until
|
||||
/// an admin acts. Fetching and contributing each reveal to a server operator
|
||||
/// that some instance holds a given title; that is inherent to the exchange, so
|
||||
/// the defaults bound the exposure rather than pretending to remove it.
|
||||
/// </remarks>
|
||||
// TRACES: JR-036, JR-038 | PR-005
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// The community manifest exchange. Shipped pre-configured but
|
||||
/// <b>disabled</b>, so no traffic leaves an installation until an admin opts
|
||||
/// in (public server specification, §9).
|
||||
/// </summary>
|
||||
public const string CommunityServerUrl = "https://jray.tourolle.paris";
|
||||
|
||||
/// <summary>
|
||||
/// Display name for <see cref="CommunityServerUrl"/>.
|
||||
/// </summary>
|
||||
public const string CommunityServerName = "JRay Community";
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||
/// </summary>
|
||||
@@ -15,6 +36,24 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
TruthFileSuffix = ".jray.json";
|
||||
CacheDurationMinutes = 60;
|
||||
EnableOverlay = true;
|
||||
|
||||
// Manifest sharing is a network egress feature, so every part of it is
|
||||
// off by default (public server specification, §9 "Configuration").
|
||||
EnableManifestSharing = false;
|
||||
ContributeManifests = false;
|
||||
ComputeAudioSignatures = false;
|
||||
MinimumMatchTier = MatchTier.Runtime;
|
||||
|
||||
// Pre-configured but disabled: the admin opts in by enabling it, rather
|
||||
// than by having to discover and type a URL.
|
||||
Servers.Add(new ManifestServer
|
||||
{
|
||||
Url = CommunityServerUrl,
|
||||
Name = CommunityServerName,
|
||||
Enabled = false,
|
||||
AllowContribute = false,
|
||||
TrustLevel = ServerTrustLevel.FetchOnly,
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@@ -37,4 +76,62 @@ public class PluginConfiguration : BasePluginConfiguration
|
||||
/// any previously injected script is removed.
|
||||
/// </summary>
|
||||
public bool EnableOverlay { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether JRay may fetch actor-timeline
|
||||
/// manifests from the configured servers. Off by default — this is a network
|
||||
/// egress feature and must be opt-in.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Fetching reveals to a server operator that some instance holds a given
|
||||
/// title. That is inherent to the exchange, and each configured server
|
||||
/// multiplies the exposure, which the configuration page states plainly.
|
||||
/// </remarks>
|
||||
public bool EnableManifestSharing { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether locally generated manifests may be
|
||||
/// contributed back. A separate opt-in from downloading, and off by default.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Contribution additionally requires <see cref="ManifestServer.AllowContribute"/>
|
||||
/// on the specific server and a token for it. Uploads are never fanned out to
|
||||
/// every configured server.
|
||||
/// </remarks>
|
||||
public bool ContributeManifests { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum cut-match tier a fetched manifest must reach
|
||||
/// before it is stored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Defaults to <see cref="MatchTier.Runtime"/>. <see cref="MatchTier.Loose"/>
|
||||
/// admits manifests whose runtime differs by up to 30s, which may be a
|
||||
/// different trim of the same cut — usable, but it should be surfaced as a
|
||||
/// caveat rather than applied silently.
|
||||
/// </remarks>
|
||||
public MatchTier MinimumMatchTier { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the plugin computes audio
|
||||
/// signatures for library items, enabling content-based cut matching and
|
||||
/// identification of files whose providence is unknown.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Off by default. Uses the FFmpeg binary Jellyfin already ships (via
|
||||
/// <c>IMediaEncoder.EncoderPath</c>), so there is no extra dependency, but it
|
||||
/// costs roughly a second or two of I/O per item and is therefore opt-in.
|
||||
/// </remarks>
|
||||
public bool ComputeAudioSignatures { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets the ordered list of manifest servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Order is the user's trust ranking: for a fetch, servers are tried in order
|
||||
/// and the first result clearing <see cref="MinimumMatchTier"/> wins. For a
|
||||
/// series, first-match applies per <i>episode</i>, so a later server is
|
||||
/// queried only for the episodes earlier ones lacked.
|
||||
/// </remarks>
|
||||
public Collection<ManifestServer> Servers { get; } = new();
|
||||
}
|
||||
|
||||
@@ -29,10 +29,16 @@
|
||||
<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.
|
||||
Shows the cast of the current scene when playback is paused. This
|
||||
requires the
|
||||
<a is="emby-linkbutton" class="button-link" href="https://github.com/IAmParadox27/jellyfin-plugin-file-transformation" target="_blank" rel="noopener">File Transformation</a>
|
||||
plugin, which rewrites the web client's index.html as it is served.
|
||||
JRay never modifies index.html on disk, so there is no fallback if
|
||||
that plugin is absent — only the overlay is affected, and every
|
||||
other JRay feature keeps working.
|
||||
</div>
|
||||
</div>
|
||||
<div id="JRayDependencyStatus" class="fieldDescription" style="margin:0 0 1.5em;padding:0.75em 1em;border-radius:0.25em;display:none;"></div>
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
|
||||
<span>Save</span>
|
||||
@@ -131,6 +137,32 @@
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---- Dependency status ----
|
||||
// Jellyfin cannot install a plugin's dependency, so the only thing
|
||||
// that closes the gap is saying so where an admin can act on it.
|
||||
function loadDependencyStatus() {
|
||||
var el = document.querySelector('#JRayDependencyStatus');
|
||||
return jrayApi('Status/Dependencies').then(function (status) {
|
||||
el.style.display = '';
|
||||
if (status.file_transformation_available) {
|
||||
el.style.background = 'rgba(82,168,82,0.15)';
|
||||
el.innerHTML = '<strong>File Transformation detected.</strong> '
|
||||
+ 'The pause overlay is served by rewriting index.html as it is '
|
||||
+ 'sent, leaving the file on disk untouched.';
|
||||
return;
|
||||
}
|
||||
|
||||
el.style.background = 'rgba(220,160,60,0.18)';
|
||||
el.innerHTML = '<strong>File Transformation is not installed — the pause '
|
||||
+ 'overlay is disabled.</strong> Every other JRay feature is unaffected. '
|
||||
+ 'To enable it, add this repository in Dashboard → Plugins → '
|
||||
+ 'Repositories, then install "File Transformation" and restart:<br />'
|
||||
+ '<code>' + escapeHtml(status.file_transformation_manifest_url) + '</code>';
|
||||
}).catch(function () {
|
||||
el.style.display = 'none';
|
||||
});
|
||||
}
|
||||
|
||||
// ---- Coverage ----
|
||||
|
||||
var JRayCoverColors = {
|
||||
@@ -340,6 +372,7 @@
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
|
||||
loadDependencyStatus();
|
||||
populateValueSelect();
|
||||
loadRules();
|
||||
loadCoverage();
|
||||
|
||||
@@ -11,12 +11,19 @@ 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.
|
||||
/// Exposes scene-actor-extraction "truth" data: which actors are present in
|
||||
/// the scene at a given timestamp.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Presence is <b>scene-scoped</b>, not instantaneous: a window is a claim about
|
||||
/// scene membership, not a recognition event, so an actor who is off-camera
|
||||
/// during a reverse shot is still present. Windows are served exactly as stored
|
||||
/// — never merged, split or trimmed.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Items/{itemId}")]
|
||||
[Authorize]
|
||||
// TRACES: JR-004, JR-005, JR-012, JR-013, JR-014 | SR-002
|
||||
public class ActorsController : ControllerBase
|
||||
{
|
||||
private readonly ITruthDataService _truthDataService;
|
||||
|
||||
@@ -19,9 +19,15 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// by genre), and supplies the genre/series option lists the config page's
|
||||
/// rule editor needs.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Percent done is <c>covered / (total - ignored)</c>: ignored items are
|
||||
/// intentionally out of scope, so excluding a genre must not drag the figure
|
||||
/// down as though it were outstanding work.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Coverage")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-018, JR-019 | PR-003
|
||||
public class CoverageController : ControllerBase
|
||||
{
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
|
||||
@@ -13,9 +13,15 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// series, or a single item; setting a rule for a target that already has one
|
||||
/// replaces it, so a target can never be both prioritised and ignored.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Rules steer work discovery only. They never reach the read endpoints or the
|
||||
/// overlay, because a rule says "don't spend compute here", not "pretend this
|
||||
/// item does not exist".
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Policy")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-016, JR-014 | PR-003
|
||||
public class PolicyController : ControllerBase
|
||||
{
|
||||
private readonly IMediaPolicyStore _policyStore;
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
using Jellyfin.Plugin.JRay.Services;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Http;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
|
||||
/// <summary>
|
||||
/// Reports whether JRay's hard dependency on the File Transformation plugin is
|
||||
/// satisfied, for the configuration page to surface.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Absent that plugin the pause overlay is disabled and every other JRay feature
|
||||
/// continues to work — so this is a status to display, not an error to raise.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Status")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-023 | PR-004
|
||||
public class StatusController : ControllerBase
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets the dependency status.
|
||||
/// </summary>
|
||||
/// <returns>The dependency status.</returns>
|
||||
[HttpGet("Dependencies")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<DependencyStatus> GetDependencies()
|
||||
{
|
||||
return Ok(new DependencyStatus
|
||||
{
|
||||
FileTransformationAvailable = Plugin.FileTransformationAvailable,
|
||||
OverlayEnabled = Plugin.OverlayEnabled,
|
||||
FileTransformationManifestUrl = FileTransformationRegistration.ManifestUrl,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -19,9 +19,19 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// Lets a remote extraction worker discover which library items still need
|
||||
/// to be processed.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The sample is random so that repeated polling spreads work across the
|
||||
/// backlog without the server tracking who holds what, and so two workers
|
||||
/// polling concurrently mostly do not collide.
|
||||
///
|
||||
/// Prioritise/ignore rules are applied <b>here and only here</b> (JR-017):
|
||||
/// they express "don't spend compute on this", not "pretend this does not
|
||||
/// exist", so they never reach the read endpoints or the overlay.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Tasks")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-015, JR-017 | PR-003
|
||||
public class TasksController : ControllerBase
|
||||
{
|
||||
private const int DefaultLimit = 10;
|
||||
|
||||
@@ -12,11 +12,18 @@ 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.
|
||||
/// locally. See SPEC.md §2.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The <c>schema_version</c> check here refuses an unrecognised version rather
|
||||
/// than guessing at its shape. It is currently the only source that checks —
|
||||
/// sidecar reads do not — which JR-003 requires be fixed by moving the check
|
||||
/// into the shared read path.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay/Items/{itemId}/Truth")]
|
||||
[Authorize(Roles = "Administrator")]
|
||||
// TRACES: JR-003, JR-009, JR-014 | SR-003
|
||||
public class TruthController : ControllerBase
|
||||
{
|
||||
private const int SupportedSchemaVersion = 1;
|
||||
|
||||
@@ -11,9 +11,14 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
||||
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
|
||||
/// injected into the web client.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Anonymous by necessity, not by oversight: the script tag is injected into
|
||||
/// <c>index.html</c>, which is served before a user has logged in.
|
||||
/// </remarks>
|
||||
[ApiController]
|
||||
[Route("Plugins/JRay")]
|
||||
[AllowAnonymous]
|
||||
// TRACES: JR-014, JR-020 | PR-001
|
||||
public class WebController : ControllerBase
|
||||
{
|
||||
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Whether JRay's one hard dependency is satisfied, and what to do if it is not.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Jellyfin has no plugin dependency mechanism — a manifest cannot declare that
|
||||
/// another plugin is required, and nothing will install one. The gap is closed by
|
||||
/// telling the admin, on the page where they can act on it. A warning that exists
|
||||
/// only in the server log is one nobody reads.
|
||||
/// </remarks>
|
||||
public class DependencyStatus
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the File Transformation plugin was
|
||||
/// found and JRay's overlay transformation registered with it.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file_transformation_available")]
|
||||
public bool FileTransformationAvailable { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the overlay is switched on in
|
||||
/// configuration. It is served only when this <i>and</i>
|
||||
/// <see cref="FileTransformationAvailable"/> hold.
|
||||
/// </summary>
|
||||
[JsonPropertyName("overlay_enabled")]
|
||||
public bool OverlayEnabled { get; set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the repository manifest URL an admin adds to install the
|
||||
/// missing dependency.
|
||||
/// </summary>
|
||||
[JsonPropertyName("file_transformation_manifest_url")]
|
||||
public string FileTransformationManifestUrl { get; set; } = string.Empty;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// The object handed to JRay's transformation callback by the File
|
||||
/// Transformation plugin.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// That plugin builds a Newtonsoft <c>JObject</c> with a single <c>contents</c>
|
||||
/// key and calls <c>JObject.ToObject(parameterType)</c> against this type.
|
||||
/// Newtonsoft binds member names case-insensitively, so <see cref="Contents"/>
|
||||
/// binds to <c>contents</c> without an attribute — and a System.Text.Json
|
||||
/// attribute would have no effect here.
|
||||
/// </remarks>
|
||||
public class TransformationPayload
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets the current contents of the file being served, including
|
||||
/// any transformations applied by other plugins ahead of JRay in the
|
||||
/// chain.
|
||||
/// </summary>
|
||||
public string? Contents { get; set; }
|
||||
}
|
||||
@@ -5,8 +5,19 @@ 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.
|
||||
/// which they are present in the scene.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// "Present in the scene", not "visible on screen": a window is a claim about
|
||||
/// scene membership, so an actor who turns away or is off-camera during a
|
||||
/// reverse shot is still present. Two windows mean a genuine departure and
|
||||
/// return, not a break in detection.
|
||||
///
|
||||
/// Identity is public identifiers — never a name alone, which is ambiguous and
|
||||
/// unstable. <c>jellyfin_id</c> is preferred locally and stripped on
|
||||
/// contribution, being meaningless outside the instance that produced it.
|
||||
/// </remarks>
|
||||
// TRACES: JR-004, JR-007 | SR-001, SR-002
|
||||
public class TruthActor
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -5,8 +5,19 @@ namespace Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
/// <summary>
|
||||
/// Root object of a scene-actor-extraction "truth" file
|
||||
/// (schema_version 1, minimal verbosity). See SPEC.md.
|
||||
/// (schema_version 1, minimal verbosity). See SPEC.md §1.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// JRay <b>owns</b> this format; the extraction pipeline is its producer and the
|
||||
/// public server carries a derived envelope. Because three repos ship
|
||||
/// independently, breaking changes are batched into one coordinated
|
||||
/// <c>schema_version</c> bump rather than made piecemeal.
|
||||
///
|
||||
/// This type is still the v1 shape. JR-002 replaces it: <c>anneal_sec</c> out,
|
||||
/// an <c>extraction</c> provenance block and a <c>cut</c> block in, and
|
||||
/// <c>scenes</c> becoming objects that carry belief and identification route.
|
||||
/// </remarks>
|
||||
// TRACES: JR-001, JR-002 | SR-003
|
||||
public class TruthFile
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace Jellyfin.Plugin.JRay;
|
||||
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
{
|
||||
private readonly ILogger<Plugin> _logger;
|
||||
private readonly bool _usingFileTransformation;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="Plugin"/> class.
|
||||
@@ -30,8 +31,25 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
Instance = this;
|
||||
_logger = logger;
|
||||
|
||||
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
|
||||
// The File Transformation plugin rewrites index.html as it is served.
|
||||
// Registration is unconditional: the transformation itself checks
|
||||
// EnableOverlay at request time, so toggling the setting takes effect
|
||||
// without re-registering.
|
||||
_usingFileTransformation = FileTransformationRegistration.TryRegister(_logger);
|
||||
|
||||
if (!_usingFileTransformation)
|
||||
{
|
||||
_logger.LogWarning(
|
||||
"JRay: the pause overlay is disabled because the File Transformation plugin " +
|
||||
"is not available. Install it from " +
|
||||
"https://github.com/IAmParadox27/jellyfin-plugin-file-transformation. " +
|
||||
"All other JRay features are unaffected.");
|
||||
}
|
||||
|
||||
// JRay never injects into index.html. This only ever *removes* a patch
|
||||
// left by an earlier version of JRay, identified by its own marker —
|
||||
// see SPEC.md JR-021/JR-022.
|
||||
WebClientPatchService.RemoveLegacyPatch(ApplicationPaths, _logger);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
@@ -45,6 +63,20 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
|
||||
/// </summary>
|
||||
public static Plugin? Instance { get; private set; }
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the pause overlay should currently be
|
||||
/// served. Read by the File Transformation callback at request time, so
|
||||
/// toggling the setting takes effect without re-registering.
|
||||
/// </summary>
|
||||
internal static bool OverlayEnabled => Instance?.Configuration.EnableOverlay ?? false;
|
||||
|
||||
/// <summary>
|
||||
/// Gets a value indicating whether the File Transformation plugin was found
|
||||
/// at startup. When it was not, the overlay is disabled and every other JRay
|
||||
/// feature continues to work.
|
||||
/// </summary>
|
||||
internal static bool FileTransformationAvailable => Instance?._usingFileTransformation ?? false;
|
||||
|
||||
/// <inheritdoc />
|
||||
public IEnumerable<PluginPageInfo> GetPages()
|
||||
{
|
||||
|
||||
@@ -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.");
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,11 @@ namespace Jellyfin.Plugin.JRay.Services;
|
||||
/// 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>
|
||||
/// <remarks>
|
||||
/// Deliberately outside the media library filesystem, so a worker that cannot
|
||||
/// write beside the media file is not a second-class producer.
|
||||
/// </remarks>
|
||||
// TRACES: JR-009, JR-010 | PR-004
|
||||
public sealed class ManagedTruthStore : IManagedTruthStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
@@ -17,6 +17,7 @@ namespace Jellyfin.Plugin.JRay.Services;
|
||||
/// reads. All access is synchronised so the work-discovery API and the
|
||||
/// config page can touch it concurrently.
|
||||
/// </summary>
|
||||
// TRACES: JR-016 | PR-003
|
||||
public sealed class MediaPolicyStore : IMediaPolicyStore
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
|
||||
@@ -11,6 +11,7 @@ namespace Jellyfin.Plugin.JRay.Services;
|
||||
/// hold only one action, the only conflicts possible are across scopes, and
|
||||
/// specificity resolves those.
|
||||
/// </summary>
|
||||
// TRACES: JR-016 | PR-003
|
||||
public static class PolicyResolver
|
||||
{
|
||||
/// <summary>
|
||||
|
||||
@@ -16,6 +16,13 @@ namespace Jellyfin.Plugin.JRay.Services;
|
||||
/// item's source file, and caches the parsed result for a configurable
|
||||
/// duration.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Managed truth — pushed by a worker, or fetched from a manifest server —
|
||||
/// takes precedence over a sidecar file. Storing fetched manifests through the
|
||||
/// managed store is what keeps this a two-way rule rather than a three-way one,
|
||||
/// so the read path never learns that the exchange exists.
|
||||
/// </remarks>
|
||||
// TRACES: JR-008, JR-010, JR-011 | PR-001
|
||||
public sealed class TruthDataService : ITruthDataService
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||
|
||||
@@ -6,26 +6,40 @@ 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.
|
||||
/// Removes the pause-overlay script tag that an earlier version of JRay
|
||||
/// injected into the web client's <c>index.html</c> on disk.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>This class cannot inject.</b> JRay reaches the web client only through
|
||||
/// <see cref="FileTransformationRegistration"/>, which rewrites
|
||||
/// <c>index.html</c> as it is served. Patching the file on disk was removed
|
||||
/// rather than left switched off: the patch outlives an uninstall, a web-client
|
||||
/// upgrade discards it silently, and it races any other plugin patching the same
|
||||
/// file. An unreachable write path is also the one nobody runs, which is the one
|
||||
/// a later refactor re-enables by accident.
|
||||
///
|
||||
/// Removal remains because users upgrading from a version that did patch the
|
||||
/// file must not be left with a stale injection pointing at endpoints that have
|
||||
/// since changed. It keys on JRay's own <c><!-- jray-overlay --></c>
|
||||
/// marker, so it is unambiguous and touches nothing another plugin added.
|
||||
/// </remarks>
|
||||
// TRACES: JR-021, JR-022 | PR-004
|
||||
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"/>.
|
||||
/// Removes a legacy on-disk overlay injection, if one is present. Safe to
|
||||
/// call on every startup: it is a no-op once the marker is gone.
|
||||
/// </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)
|
||||
public static void RemoveLegacyPatch(IApplicationPaths applicationPaths, ILogger logger)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(applicationPaths);
|
||||
ArgumentNullException.ThrowIfNull(logger);
|
||||
|
||||
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
|
||||
|
||||
try
|
||||
@@ -37,36 +51,38 @@ public static class WebClientPatchService
|
||||
}
|
||||
|
||||
var html = File.ReadAllText(indexPath);
|
||||
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
|
||||
if (!html.Contains(Marker, StringComparison.Ordinal))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
var cleaned = RemoveInjection(html);
|
||||
File.WriteAllText(indexPath, cleaned);
|
||||
logger.LogInformation(
|
||||
"JRay: removed a pause-overlay script left in {Path} by an earlier version. "
|
||||
+ "JRay no longer modifies this file; the overlay is served through File Transformation.",
|
||||
indexPath);
|
||||
}
|
||||
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
|
||||
{
|
||||
logger.LogWarning(ex, "JRay: failed to patch web client index.html at {Path}", indexPath);
|
||||
logger.LogWarning(ex, "JRay: failed to remove the legacy overlay patch from {Path}", indexPath);
|
||||
}
|
||||
}
|
||||
|
||||
private static string ReplaceLast(string source, string find, string replace)
|
||||
/// <summary>
|
||||
/// Strips the marked script tag from the document.
|
||||
/// </summary>
|
||||
/// <param name="html">The document contents.</param>
|
||||
/// <returns>The contents with JRay's injection removed.</returns>
|
||||
internal static string RemoveInjection(string html)
|
||||
{
|
||||
var index = source.LastIndexOf(find, StringComparison.Ordinal);
|
||||
if (index < 0)
|
||||
{
|
||||
return source;
|
||||
}
|
||||
ArgumentNullException.ThrowIfNull(html);
|
||||
|
||||
return source[..index] + replace + source[(index + find.Length)..];
|
||||
// The trailing newline is stripped with the tag when present, so removing
|
||||
// a patch restores the document byte-for-byte rather than leaving a blank
|
||||
// line that accumulates across upgrades.
|
||||
return html
|
||||
.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
|
||||
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,3 +1,18 @@
|
||||
/*
|
||||
* JRay pause overlay: lists the cast of the scene the viewer paused in.
|
||||
*
|
||||
* Presence is scene-scoped. An actor who has turned away, is occluded, or is
|
||||
* off-camera during a reverse shot is still in the scene, so this must not be
|
||||
* presented as "who is visible right now" — that is a different, and weaker,
|
||||
* claim than the data makes.
|
||||
*
|
||||
* Every server-supplied string is written with textContent, never innerHTML.
|
||||
* With the manifest exchange these strings may originate from a third-party
|
||||
* server, and this is the one control that holds even if every other check is
|
||||
* bypassed.
|
||||
*
|
||||
* TRACES: JR-005, JR-020, JR-024 | SR-002, SR-004
|
||||
*/
|
||||
(function () {
|
||||
'use strict';
|
||||
|
||||
|
||||
@@ -25,6 +25,25 @@ https://gitea.tourolle.paris/dtourolle/jRay/raw/branch/master/manifest.json
|
||||
|
||||
Then install "JRay" from the plugin catalog and restart Jellyfin.
|
||||
|
||||
### Required for the overlay: File Transformation
|
||||
|
||||
JRay's pause overlay needs a script tag in the web client's `index.html`.
|
||||
Install [File Transformation](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation)
|
||||
(repository `https://www.iamparadox.dev/jellyfin/plugins/manifest.json`) **before**
|
||||
installing JRay. It rewrites the page as it is served, so the file on disk is
|
||||
never touched — that survives server upgrades and coexists with other plugins
|
||||
patching the same file.
|
||||
|
||||
**There is no fallback.** JRay never edits `index.html` on disk: a patch there
|
||||
outlives an uninstall, is silently discarded by a web-client upgrade, and races
|
||||
any other plugin touching the file. Without File Transformation the pause
|
||||
overlay is simply disabled — every other JRay feature works normally, and the
|
||||
plugin's configuration page tells you what is missing and how to install it.
|
||||
|
||||
Upgrading from an older JRay that did patch `index.html`? It removes its own
|
||||
patch on startup, so there is nothing to clean up by hand. See
|
||||
[SPEC.md](SPEC.md) JR-021/JR-022.
|
||||
|
||||
## Features
|
||||
|
||||
- **Pause overlay** — pause a movie or episode in the web client and see the
|
||||
|
||||
@@ -1,294 +1,786 @@
|
||||
# JRay truth file format
|
||||
# jRay — software specification
|
||||
|
||||
JRay reads "truth" files produced offline by the
|
||||
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
|
||||
pipeline (`result_sink_node`, `Verbosity::minimal`, `schema_version: 1`).
|
||||
Status: **alpha.** Core read path ships; the schema bump, the exchange client and
|
||||
the audio signature do not.
|
||||
|
||||
## File location
|
||||
This is a *software* spec: its job is to implement the
|
||||
[system spec](scripts/vendor/jray-project/SPEC.md), which owns everything spanning more than one repo.
|
||||
Requirements here trace up to an `SR-nnn` or a `PR-nnn`; the prose below is the
|
||||
detail. The authoritative ID list with status lives in
|
||||
[`docs/requirements.md`](docs/requirements.md).
|
||||
|
||||
For a media file `Movie.mkv`, the pipeline writes a sibling file
|
||||
`Movie.jray.json` (suffix configurable in the plugin settings, default
|
||||
`.jray.json`). The plugin resolves this path from the Jellyfin item's media
|
||||
source path by stripping the extension and appending the suffix.
|
||||
jRay is the Jellyfin plugin: it **consumes** presence data, **displays** it in
|
||||
the player, and **owns the truth-file format** that the other two components
|
||||
produce and exchange.
|
||||
|
||||
## JSON schema (schema_version 1, minimal verbosity)
|
||||
---
|
||||
|
||||
## 0. Requirements
|
||||
|
||||
IDs are `JR-nnn`, zero-padded and **permanent** — a withdrawn requirement keeps
|
||||
its number, because renumbering is what produces orphan TRACES tags
|
||||
([system spec](scripts/vendor/jray-project/SPEC.md) §6).
|
||||
|
||||
| Group | IDs | Where addressed |
|
||||
|---|---|---|
|
||||
| Truth-file format | JR-001 … JR-007 | §1 |
|
||||
| Sources and precedence | JR-008 … JR-011 | §2 |
|
||||
| Read API | JR-012 … JR-014 | §3 |
|
||||
| Work discovery, policy, coverage | JR-015 … JR-019 | §4 |
|
||||
| Player overlay | JR-020 … JR-024 | §5 |
|
||||
| Manifest exchange client | JR-025 … JR-037 | §6 |
|
||||
| Egress and privacy | JR-038 … JR-041 | §7 |
|
||||
| Audio signature | JR-042 … JR-045 | §8 |
|
||||
| Human-in-the-loop association | JR-046 | §9 |
|
||||
|
||||
**JR-038 … JR-041 exist because `PR-005` had no software row anywhere.** The
|
||||
system spec notes that "leak nothing about what the user owns" is preserved
|
||||
structurally — by SR-004 and GR-005 both being prohibitions — and that a goal
|
||||
held only by prohibitions needs watching. jRay is the component that actually
|
||||
opens a socket, so it is the right place for that goal to become checkable.
|
||||
|
||||
---
|
||||
|
||||
## 1. Truth-file format — JR-001 … JR-007
|
||||
|
||||
### JR-001 — This document is normative for the format
|
||||
|
||||
The truth file is produced by `scene-actor-extraction`, read by this plugin, and
|
||||
transformed into a Jmanifest by the exchange client. Three repos touch it, so
|
||||
exactly one must define it, and the [system spec](scripts/vendor/jray-project/SPEC.md) §1 assigns that to
|
||||
jRay. Other repos reference this section rather than restating the schema.
|
||||
|
||||
The truth file is **not** the Jmanifest. It carries installation-local fields
|
||||
(`movie`, `jellyfin_id`) that the exchange strips, and lacks the portable
|
||||
identity block the exchange adds. See §6.
|
||||
|
||||
**Current:** [`Models/TruthFile.cs`](Jellyfin.Plugin.JRay/Models/TruthFile.cs),
|
||||
`schema_version: 1`. **Gap:** the schema below is not implemented, and the other
|
||||
two specs describe the pending bump in more detail than this one does — the
|
||||
ownership is stated but not yet exercised.
|
||||
|
||||
### JR-002 — `schema_version: 2`
|
||||
|
||||
```json
|
||||
{
|
||||
"schema_version": 1,
|
||||
"movie": "/path/to/Movie.mkv",
|
||||
"sample_fps": 1,
|
||||
"anneal_sec": 2,
|
||||
"schema_version": 2,
|
||||
"movie": "/data/movies/Movie.mkv",
|
||||
"extraction": {
|
||||
"sample_fps": 5,
|
||||
"extinction_sec": 12,
|
||||
"gallery_size": 1820,
|
||||
"gallery_scope": "global",
|
||||
"pipeline_version": "scene-actor-extraction 0.4.1"
|
||||
},
|
||||
"cut": {
|
||||
"runtime_sec": 6420.5,
|
||||
"audio_signature": "v1:v7fA3k…"
|
||||
},
|
||||
"actors": [
|
||||
{
|
||||
"name": "Tom Hanks",
|
||||
"imdb_id": "nm0000158",
|
||||
"tmdb_id": "31",
|
||||
"name": "Steve Buscemi",
|
||||
"imdb_id": "nm0000114",
|
||||
"tmdb_id": "884",
|
||||
"jellyfin_id": "abc123-guid",
|
||||
"scenes": [[12.0, 45.0], [102.5, 150.0]]
|
||||
"scenes": [
|
||||
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
|
||||
{ "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
- `schema_version`: integer, bump on breaking changes. JRay should refuse (or
|
||||
warn) on a version it doesn't understand.
|
||||
- `movie`: absolute path to the source media file at extraction time (informational only).
|
||||
- `sample_fps`: frames-per-second the pipeline sampled at.
|
||||
- `anneal_sec`: gap (in seconds) below which consecutive detections of the
|
||||
same actor were merged into a single scene window.
|
||||
- `actors[]`: one entry per actor detected anywhere in the film.
|
||||
- `name`: display name from the gallery.
|
||||
- `imdb_id` / `tmdb_id` / `jellyfin_id`: identity keys, each `""` if not
|
||||
resolved. JRay should prefer `jellyfin_id` (a Jellyfin Person item GUID)
|
||||
when non-empty, and otherwise resolve `imdb_id`/`tmdb_id` against the
|
||||
item's People `ProviderIds`.
|
||||
- `scenes`: list of `[start_sec, end_sec]` windows (inclusive) during which
|
||||
the actor is on screen.
|
||||
Field notes:
|
||||
|
||||
## Querying "who's on screen at time t"
|
||||
- `schema_version` — **system-level** (SR-003), incremented once per breaking
|
||||
change and referenced by the same number in all three repos.
|
||||
- `movie` — absolute path at extraction time, informational only. Stripped on
|
||||
contribution (JR-034).
|
||||
- `extraction.*` — provenance. `sample_fps`, `gallery_size` and
|
||||
`pipeline_version` move here from the top level so the truth file and the
|
||||
Jmanifest's `extraction` block have the same shape, rather than differing for
|
||||
no reason.
|
||||
- `extraction.extinction_sec` — **replaces `anneal_sec`**, which is deleted, not
|
||||
retained as a vestigial `0`. It is the re-acquisition timeout that shapes
|
||||
window extent, so it is what a consumer needs in order to interpret a window.
|
||||
- `extraction.gallery_scope` — `"global"` or `"limited"`. The strongest single
|
||||
quality signal when two manifests compete for one cut.
|
||||
- `cut.runtime_sec` — the decoded duration the timings came from. Required for
|
||||
contribution; the primary alignment guard.
|
||||
- `cut.audio_signature` — optional, `v1:`-prefixed. See §8.
|
||||
- `actors[].scenes[]` — objects, not float pairs. `start`/`end` in seconds,
|
||||
inclusive, sorted. `belief` is the accumulated posterior that justified the
|
||||
claim; `route` is `"live"`, `"deferred"` or `"pooled"` (extraction AR-017).
|
||||
|
||||
For a given timestamp `t` (seconds), an actor is visible if any of their
|
||||
`scenes` windows satisfies `start <= t <= end`.
|
||||
**Belief is an attribute, not part of identity.** Two servers that validated the
|
||||
same upload must agree on its `content_id`, and belief is a producer-side
|
||||
estimate that may legitimately differ between pipeline versions for identical
|
||||
timings. It replicates the way `audio_signature` does — see the server spec §9a.
|
||||
|
||||
## API
|
||||
**Gap:** entire requirement. The changes are all breaking and ship as **one**
|
||||
bump (SR-003), together with extraction `IR-002` and the server's acceptance of
|
||||
the new shape.
|
||||
|
||||
All read endpoints below require an authenticated Jellyfin user token (passed as
|
||||
`X-Emby-Token` or `Authorization: MediaBrowser Token="..."`); the admin endpoints
|
||||
additionally require the **Administrator** role. Only `GET /Plugins/JRay/ClientScript`
|
||||
is anonymous.
|
||||
### JR-003 — Unknown `schema_version` is refused, never guessed
|
||||
|
||||
### `GET /Plugins/JRay/Items/{itemId}/Timeline`
|
||||
**Decision: flag day.** The plugin accepts `schema_version: 2` and rejects
|
||||
everything else, on every path — sidecar read, managed `PUT`, and fetched
|
||||
manifest. There is no transitional dual-accept.
|
||||
|
||||
Returns the full truth file (schema above) for an item, or `404` if no truth
|
||||
data exists (neither a managed upload nor a sidecar file). Requires an
|
||||
authenticated user token.
|
||||
All three components are pre-release and move together, and the alternative
|
||||
carries a cost that outlasts the transition: a v1 read path is the one nobody
|
||||
exercises, so it is the one that rots, and it would have to be carried through
|
||||
every subsequent change to the reader.
|
||||
|
||||
### `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
|
||||
**The consequence must be stated plainly rather than discovered:** existing v1
|
||||
sidecar files on disk **stop being read** at the bump, and stay dark until the
|
||||
library is re-extracted. The plugin logs this per item, naming the file and the
|
||||
version found, rather than silently reporting no coverage — an item that looks
|
||||
un-extracted when it was merely stale is the failure mode that wastes a user's
|
||||
compute.
|
||||
|
||||
Returns an extensible "context at time t" envelope, or `404` if no truth
|
||||
data exists for the item. Requires an authenticated user token:
|
||||
**Current:** `PUT` rejects `schema_version != 1` with `400`; sidecar reads do not
|
||||
check the version at all. **Gap:** the version check must move into the shared
|
||||
read path so all three sources are covered, and the target becomes `2`.
|
||||
|
||||
### JR-004 — A window is a scene-membership claim
|
||||
|
||||
**This is SR-002, and it binds this plugin harder than it binds anything else,**
|
||||
because jRay is where the claim reaches a human.
|
||||
|
||||
An actor who turns away, is occluded, or is off-camera while the shot cuts to
|
||||
whoever they are speaking to **is still present**. Two windows mean a genuine
|
||||
departure and return, not a break in detection. Gaps shorter than
|
||||
`extinction_sec` were absorbed upstream and are claimed as presence.
|
||||
|
||||
The plugin therefore **never reinterprets, merges, splits, or trims windows.**
|
||||
It stores and serves what it was given. The one permitted transformation is the
|
||||
timebase offset of JR-030, which shifts every window uniformly and so preserves
|
||||
the claim.
|
||||
|
||||
**Gap:** stated nowhere in the code today. The read path happens to comply, but
|
||||
by not having been written to do otherwise rather than by requirement.
|
||||
|
||||
### JR-005 — Query semantics, and how presence is presented
|
||||
|
||||
An actor is present at `t` if any window satisfies `start <= t <= end`.
|
||||
|
||||
**The presentation must not assert instantaneous visibility.** SR-002 is explicit
|
||||
that a consumer must never interpret window boundaries as "the face was detected
|
||||
here", and the overlay is the exact place that misreading would be made
|
||||
user-visible. "On screen now" is a claim the data does not support; "in this
|
||||
scene" is the claim it does.
|
||||
|
||||
This is a wording requirement, not a hedge — it is the difference between the
|
||||
product being right and being a worse version of a frame-by-frame detector.
|
||||
|
||||
**Current:** the query is implemented correctly in
|
||||
[`ActorsController`](Jellyfin.Plugin.JRay/Controllers/ActorsController.cs).
|
||||
**Gap:** wording, not logic. The overlay renders a bare list with **no heading
|
||||
at all**, so it asserts nothing — but it also tells the viewer nothing about
|
||||
what the list means, and a viewer's default reading of a paused frame is "these
|
||||
people are on screen". [`README.md`](README.md) states that reading outright
|
||||
("which actors are on screen at that exact moment"), and the model type is
|
||||
`ActorAtTime`.
|
||||
|
||||
### JR-006 — Numerous windows
|
||||
|
||||
SR-002 warns that windows may be numerous and consumers must not assume a handful
|
||||
of long ones. Track-extent presence with a short `extinction_sec` produces many
|
||||
short windows per actor, and the previous design's few long ones were an artefact
|
||||
of the over-claiming that was removed.
|
||||
|
||||
The read path must therefore treat per-actor windows as a sorted sequence to be
|
||||
searched, not a short list to be scanned, and the `jray?t=` response must stay
|
||||
small regardless of how many windows an actor has.
|
||||
|
||||
**Gap:** windows are scanned linearly and the whole truth file is held per item.
|
||||
Adequate at current sizes; unmeasured, and unstated until now.
|
||||
|
||||
### JR-007 — Identity is public identifiers
|
||||
|
||||
Each actor carries `imdb_id`, `tmdb_id` and `jellyfin_id`, any of which may be
|
||||
`""`. Resolution prefers `jellyfin_id` (a Jellyfin Person GUID) when non-empty,
|
||||
and otherwise matches `imdb_id`/`tmdb_id` against the item's People
|
||||
`ProviderIds`. Never a name alone — names are ambiguous and unstable (SR-001).
|
||||
|
||||
`jellyfin_id` is the exception that proves the rule: it is meaningful only on the
|
||||
instance that produced it, which is exactly why the exchange strips it (JR-034).
|
||||
|
||||
**Current:** implemented. **Gap:** none.
|
||||
|
||||
---
|
||||
|
||||
## 2. Truth-data sources and precedence — JR-008 … JR-011
|
||||
|
||||
### JR-008 — Sidecar discovery
|
||||
|
||||
For `Movie.mkv`, the plugin looks for `Movie.jray.json` beside it — suffix
|
||||
configurable, default `.jray.json`, resolved from the item's media source path.
|
||||
|
||||
**Current:** implemented. **Gap:** none.
|
||||
|
||||
### JR-009 — Managed truth push
|
||||
|
||||
`PUT`/`DELETE .../Truth` let a worker that cannot write beside the media file
|
||||
deliver results over HTTP. Stored under the plugin's configuration directory,
|
||||
keyed by item id, independent of the library filesystem.
|
||||
|
||||
**Current:** implemented. **Gap:** none.
|
||||
|
||||
### JR-010 — Precedence and provenance
|
||||
|
||||
There are now **three** sources: a sidecar file, a push from a local worker, and
|
||||
a manifest fetched from a server. Managed truth — pushed *or* fetched — takes
|
||||
precedence over a sidecar.
|
||||
|
||||
**Fetched manifests are stored through the managed store**, so precedence stays a
|
||||
two-way rule rather than a three-way one, and the read path does not learn about
|
||||
the exchange at all.
|
||||
|
||||
But the three are no longer interchangeable, so **provenance is recorded with the
|
||||
stored truth**: which source it came from, and for a fetched one, which server
|
||||
and at what match tier. A `loose`-tier fetch from a third-party server and a
|
||||
locally-computed sidecar are not the same claim, and JR-036 requires the
|
||||
difference be surfaceable.
|
||||
|
||||
**Current:** two-way precedence implemented in
|
||||
[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs).
|
||||
**Gap:** no provenance is recorded.
|
||||
|
||||
### JR-011 — Caching
|
||||
|
||||
Loaded truth is cached in memory for a configurable duration. Any write —
|
||||
managed `PUT`, `DELETE`, or a stored fetch — invalidates that item's entry
|
||||
immediately, so a push takes effect without waiting for expiry.
|
||||
|
||||
**Current:** implemented. **Gap:** none.
|
||||
|
||||
---
|
||||
|
||||
## 3. Read API — JR-012 … JR-014
|
||||
|
||||
### JR-012 — `GET /Plugins/JRay/Items/{itemId}/Timeline`
|
||||
|
||||
Returns the full truth file (§1), or `404` if no truth data exists from any
|
||||
source.
|
||||
|
||||
### JR-013 — `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
|
||||
|
||||
Returns an extensible "context at time `t`" envelope, or `404`:
|
||||
|
||||
```json
|
||||
{
|
||||
"actors": [
|
||||
{ "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
|
||||
{ "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884", "jellyfin_id": "abc123-guid" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Future fields (e.g. `locations`, `trivia`) will be added to this object
|
||||
without changing the route, so clients should ignore unknown keys.
|
||||
Future fields (`locations`, `trivia`, and per JR-005 a presence caveat) are added
|
||||
to this object without changing the route, so **clients must ignore unknown
|
||||
keys**.
|
||||
|
||||
### `PUT /Plugins/JRay/Items/{itemId}/Truth`
|
||||
### JR-014 — Authorisation
|
||||
|
||||
For servers that cannot run the extraction pipeline locally, a remote worker
|
||||
may push truth data directly. Requires an administrator API key. Body is a
|
||||
truth file (schema above). Returns `204` on success, or `400` if
|
||||
`schema_version` is not `1`.
|
||||
| Route | Requires |
|
||||
|---|---|
|
||||
| `Timeline`, `jray?t=` | Authenticated Jellyfin user token |
|
||||
| `Truth`, `Tasks/*`, `Policy/*`, `Coverage/*`, and §6's fetch routes | **Administrator** role |
|
||||
| `ClientScript` | Anonymous — it is injected into a page served before login |
|
||||
|
||||
This "managed" truth data takes precedence over any sidecar
|
||||
`Movie.jray.json` file for the same item, and is stored independently of the
|
||||
media library filesystem.
|
||||
**Current:** all three implemented as stated. **Gap:** none.
|
||||
|
||||
### `DELETE /Plugins/JRay/Items/{itemId}/Truth`
|
||||
---
|
||||
|
||||
Removes managed truth data for an item (idempotent, always returns `204`).
|
||||
The item falls back to its sidecar truth file, if any, on subsequent reads.
|
||||
Requires an administrator API key.
|
||||
## 4. Work discovery, policy and coverage — JR-015 … JR-019
|
||||
|
||||
### `GET /Plugins/JRay/ClientScript`
|
||||
### JR-015 — `GET /Plugins/JRay/Tasks/Pending?limit=10`
|
||||
|
||||
Serves the pause-overlay script that JRay injects into the web client's
|
||||
`index.html` (see below). Anonymous access.
|
||||
|
||||
### `GET /Plugins/JRay/Tasks/Pending?limit=10`
|
||||
|
||||
Lets a remote extraction worker discover what to work on next. Returns a
|
||||
random sample (default 10, max 100) of movies/episodes in the library that
|
||||
have no truth data yet (neither a managed upload nor a sidecar file):
|
||||
A **random** sample (default 10, max 100) of movies and episodes with no truth
|
||||
data:
|
||||
|
||||
```json
|
||||
[
|
||||
{ "item_id": "abc123-guid", "path": "/data/movies/Movie.mkv", "name": "Movie" }
|
||||
]
|
||||
[ { "item_id": "abc123-guid", "path": "/data/movies/Movie.mkv", "name": "Movie" } ]
|
||||
```
|
||||
|
||||
Requires an administrator API key. The sample is random, so repeated polling
|
||||
naturally spreads work across the backlog without needing server-side task
|
||||
tracking; an empty array means there's nothing left to do (or every remaining
|
||||
item is a virtual/missing-path item that JRay can't process).
|
||||
Randomness is the design: repeated polling spreads work across the backlog
|
||||
without the server tracking who is working on what, and two workers polling
|
||||
concurrently mostly do not collide. An empty array means nothing is left, or that
|
||||
everything remaining is a virtual/missing-path item.
|
||||
|
||||
**Prioritise/ignore rules apply here.** Items covered by an **ignore** rule are
|
||||
never returned. Items covered by a **prioritise** rule are returned ahead of
|
||||
un-prioritised items (still randomised within each tier). See
|
||||
[Prioritise / ignore rules](#prioritise--ignore-rules) below. Rules only affect
|
||||
this work-discovery endpoint — they never change the overlay or the read
|
||||
endpoints, so an item you ignore for extraction still shows its overlay if truth
|
||||
data happens to exist for it.
|
||||
### JR-016, JR-017 — Prioritise / ignore rules
|
||||
|
||||
## Prioritise / ignore rules
|
||||
Rules steer the queue: each targets a **genre**, a **series**, or an **item**,
|
||||
and either prioritises (front of the queue) or ignores (hidden entirely). This is
|
||||
how an admin says "never extract anime", "this series first", or "skip this one".
|
||||
|
||||
Admins can steer the work-discovery queue with a small set of **rules**. Each
|
||||
rule targets a **genre**, a **series**, or a single **item**, and either
|
||||
**prioritises** (moves matching items to the front of `Tasks/Pending`) or
|
||||
**ignores** them (hides them from `Tasks/Pending` entirely). This is how you
|
||||
say "never extract anime", "process this series first", or "skip this one
|
||||
movie".
|
||||
|
||||
Rule resolution for an item picks the **most specific** matching scope:
|
||||
`Item` overrides `Series`, which overrides `Genre`. A rule is uniquely keyed by
|
||||
its scope + value, and setting a rule for an existing scope+value **replaces**
|
||||
it — so a single target can never be both prioritised and ignored. (An item can
|
||||
still be pulled in two directions across scopes, e.g. a prioritised series in an
|
||||
ignored genre; specificity resolves that — the series rule wins.)
|
||||
|
||||
Rules are persisted to `policy.json` under the plugin's configuration directory.
|
||||
A rule object:
|
||||
Resolution picks the **most specific** match: `Item` > `Series` > `Genre`. A rule
|
||||
is keyed by scope + value, and setting one replaces any existing rule for the
|
||||
same key — so a single target can never be simultaneously prioritised and
|
||||
ignored. Cross-scope conflicts (a prioritised series inside an ignored genre) are
|
||||
resolved by specificity: the series wins.
|
||||
|
||||
```json
|
||||
{ "scope": "Genre", "value": "Anime", "action": "Ignore", "label": "Anime" }
|
||||
```
|
||||
|
||||
- `scope`: `"Genre"`, `"Series"`, or `"Item"`.
|
||||
- `value`: a genre name (for `Genre`), a series id GUID (for `Series`), or an
|
||||
item id GUID (for `Item`). Genre matching is case-insensitive.
|
||||
- `action`: `"Prioritise"` or `"Ignore"`.
|
||||
- `label`: optional human-readable label shown in the config UI (informational).
|
||||
`value` is a genre name, a series id GUID, or an item id GUID; genre matching is
|
||||
case-insensitive. Persisted to `policy.json` in the plugin's configuration
|
||||
directory.
|
||||
|
||||
All endpoints below require an **Administrator** API key.
|
||||
**JR-017 is the constraint worth stating separately: rules affect work discovery
|
||||
only.** They never change the overlay or the read endpoints. An item you ignore
|
||||
for extraction still shows its overlay if truth data happens to exist — because
|
||||
the rule expresses "don't spend compute here", not "pretend this doesn't exist".
|
||||
|
||||
### `GET /Plugins/JRay/Policy/Rules`
|
||||
Endpoints: `GET`/`PUT /Plugins/JRay/Policy/Rules`, and
|
||||
`DELETE /Plugins/JRay/Policy/Rules?scope=&value=` (idempotent).
|
||||
|
||||
Returns all configured rules as a JSON array of rule objects.
|
||||
### JR-018 — `GET /Plugins/JRay/Coverage`
|
||||
|
||||
### `PUT /Plugins/JRay/Policy/Rules`
|
||||
|
||||
Adds or replaces a rule (body is a single rule object). Replaces any existing
|
||||
rule with the same `scope` + `value`. Returns `204`, or `400` if `value` is
|
||||
empty.
|
||||
|
||||
### `DELETE /Plugins/JRay/Policy/Rules?scope={scope}&value={value}`
|
||||
|
||||
Removes the rule matching `scope` + `value`. Idempotent, always returns `204`.
|
||||
|
||||
## Coverage overview
|
||||
|
||||
### `GET /Plugins/JRay/Coverage`
|
||||
|
||||
Returns how much of the library has truth data, overall and broken down by
|
||||
media type (Film vs TV) and by genre. Requires an **Administrator** API key.
|
||||
How much of the library has truth data, overall and by media type and genre:
|
||||
|
||||
```json
|
||||
{
|
||||
"total": { "total": 1200, "covered": 300, "pending": 850, "prioritised": 40, "ignored": 50 },
|
||||
"by_media_type": [
|
||||
{ "label": "Film", "counts": { "total": 400, "covered": 200, "pending": 190, "prioritised": 10, "ignored": 10 } },
|
||||
{ "label": "TV", "counts": { "total": 800, "covered": 100, "pending": 660, "prioritised": 30, "ignored": 40 } }
|
||||
],
|
||||
"by_genre": [
|
||||
{ "label": "Anime", "counts": { "total": 120, "covered": 0, "pending": 0, "prioritised": 0, "ignored": 120 } }
|
||||
]
|
||||
"by_media_type": [ { "label": "Film", "counts": { "…": 0 } } ],
|
||||
"by_genre": [ { "label": "Anime", "counts": { "…": 0 } } ]
|
||||
}
|
||||
```
|
||||
|
||||
Each `counts` object buckets items as: `covered` (has truth data),
|
||||
`pending` (needs processing and not ignored; `prioritised` is the subset of
|
||||
`pending` under a prioritise rule), and `ignored` (excluded by an ignore rule).
|
||||
`total` is the sum. A useful "percent done" is `covered / (total - ignored)`,
|
||||
so ignoring a genre or series does **not** drag the percentage down — ignored
|
||||
items are treated as intentionally out of scope.
|
||||
`prioritised` is a subset of `pending`. Percent done is
|
||||
`covered / (total - ignored)` — **ignoring a genre does not drag the percentage
|
||||
down**, because ignored items are intentionally out of scope, not outstanding
|
||||
work. An item counts toward every genre it carries, so genre rows overlap and
|
||||
need not sum to the library total.
|
||||
|
||||
An item counts toward every genre it carries, so genre rows can overlap and
|
||||
their totals need not sum to the library total.
|
||||
### JR-019 — Pickers
|
||||
|
||||
### Pickers for the config UI
|
||||
`Coverage/Genres`, `Coverage/Series`, and `Coverage/Items?search=&limit=`
|
||||
populate the rule editor's dropdowns, each returning
|
||||
`[{ "value": …, "label": … }]`. An absent `search` returns `[]`.
|
||||
|
||||
Three helper endpoints populate the rule editor's dropdowns (all require an
|
||||
**Administrator** API key, all return `[{ "value": ..., "label": ... }]`):
|
||||
**Current (JR-015 … JR-019):** all implemented. **Gap:** none functionally —
|
||||
these traced to no requirement until this register existed, which by the gate's
|
||||
own definition read as scope creep. They serve PR-003 (fully automatic, no
|
||||
per-title manual work): steering a queue is how automation is directed without
|
||||
becoming per-title labour.
|
||||
|
||||
- `GET /Plugins/JRay/Coverage/Genres` — distinct genres present on movies/episodes.
|
||||
- `GET /Plugins/JRay/Coverage/Series` — series in the library (`value` is the series id).
|
||||
- `GET /Plugins/JRay/Coverage/Items?search={term}&limit={n}` — movies/episodes
|
||||
whose name matches `term` (`value` is the item id; `limit` default 25, max
|
||||
100). An empty/absent `search` returns `[]`.
|
||||
---
|
||||
|
||||
## Client: pushing results from a remote extraction worker
|
||||
## 5. Player overlay — JR-020 … JR-024
|
||||
|
||||
A worker that runs the extraction pipeline on a different machine than
|
||||
Jellyfin (i.e. it cannot write a `Movie.jray.json` sidecar next to the media
|
||||
file) can push results directly over HTTP.
|
||||
### JR-020 — The overlay
|
||||
|
||||
To find work, poll `GET /Plugins/JRay/Tasks/Pending?limit=10` (see above) for
|
||||
a random batch of items that still need processing, instead of walking the
|
||||
whole library and checking each item's `Timeline`/sidecar yourself.
|
||||
Jellyfin has no plugin hook for player UI, so jRay adds
|
||||
`<script defer src="/Plugins/JRay/ClientScript"></script>` to the web client's
|
||||
`index.html`. The script listens for the player's pause event, calls `jray?t=`
|
||||
for the current item and timestamp, and renders the scene's cast.
|
||||
|
||||
### 1. Authenticate
|
||||
Per JR-005 it presents scene membership, not instantaneous visibility.
|
||||
|
||||
Create an **Administrator** API key in Jellyfin (Dashboard → API Keys), and
|
||||
send it on every request as either:
|
||||
### JR-021 — jRay never injects into `index.html` on disk
|
||||
|
||||
```
|
||||
X-Emby-Token: <api-key>
|
||||
**File Transformation is a hard requirement, not a preference. There is no
|
||||
on-disk patching fallback.**
|
||||
|
||||
The prohibition is on **injection**, not on writing: JR-022's migration must
|
||||
write to the file in order to remove a legacy patch. Stating it as "never
|
||||
writes" would put the two requirements in contradiction, and the static check
|
||||
would have to be disabled to let the migration through — so the check is that no
|
||||
code path *adds* the script tag.
|
||||
|
||||
At startup jRay looks for the
|
||||
[File Transformation](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation)
|
||||
assembly via `AssemblyLoadContext` and, if present, calls
|
||||
`Jellyfin.Plugin.FileTransformation.PluginInterface.RegisterTransformation` by
|
||||
reflection — no compile-time dependency, so jRay loads normally when it is
|
||||
absent. The payload:
|
||||
|
||||
```json
|
||||
{
|
||||
"id": "2c9b5a41-6ad0-4c1e-9f7d-1d1e6b0d5a90",
|
||||
"fileNamePattern": "index.html",
|
||||
"callbackAssembly": "<jRay assembly full name>",
|
||||
"callbackClass": "Jellyfin.Plugin.JRay.Services.FileTransformationRegistration",
|
||||
"callbackMethod": "TransformIndexHtml"
|
||||
}
|
||||
```
|
||||
|
||||
or:
|
||||
File Transformation matches `callbackAssembly` against `Assembly.FullName`
|
||||
exactly, so the full display name is sent. The callback is a public static method
|
||||
taking a payload with a `contents` string and returning the transformed string;
|
||||
the payload binds with Newtonsoft, which matches property names
|
||||
case-insensitively. Registration is unconditional at startup — the callback
|
||||
itself checks the "enable overlay" setting per request, so toggling takes effect
|
||||
without re-registering.
|
||||
|
||||
```
|
||||
Authorization: MediaBrowser Token="<api-key>"
|
||||
```
|
||||
Writing to `index.html` is rejected because it is destructive in ways a plugin
|
||||
cannot clean up after:
|
||||
|
||||
### 2. Resolve the Jellyfin item id
|
||||
- **It outlives the plugin.** Uninstalling jRay leaves the patch in a file jRay
|
||||
no longer owns.
|
||||
- **It breaks on upgrade.** A web-client update replaces the file, discarding the
|
||||
patch — or preserves one pointing at an endpoint that has since changed.
|
||||
- **It collides.** Another plugin patching the same file races with jRay, and the
|
||||
loser's edit is lost with no diagnostic.
|
||||
- **It is a second code path**, and it is the one nobody runs, so it is the one
|
||||
that rots.
|
||||
|
||||
The push endpoint is keyed by the Jellyfin item GUID, not by file path. To
|
||||
find it for `Movie.mkv`:
|
||||
**Current:** satisfied.
|
||||
[`WebClientPatchService`](Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs)
|
||||
is removal-only — the injection capability is *deleted*, not switched off, since
|
||||
dead code with a live signature is what a later refactor re-enables by accident.
|
||||
Enforced by
|
||||
[`scripts/checks/no-index-injection.sh`](scripts/checks/no-index-injection.sh),
|
||||
which was verified to fail on a reintroduced injection rather than merely to pass
|
||||
today. [`README.md`](README.md) no longer advertises a fallback. **Gap:** none.
|
||||
|
||||
### JR-022 — Migrate away from earlier on-disk patches
|
||||
|
||||
Users upgrading from a version that patched the file must not be left with a
|
||||
stale injection. On startup jRay removes any on-disk patch bearing its own
|
||||
`<!-- jray-overlay -->` marker — unambiguous, and touching nothing another plugin
|
||||
added.
|
||||
|
||||
**Current:** implemented — `WebClientPatchService.RemoveLegacyPatch` runs at
|
||||
every startup and is a no-op once the marker is gone. The strip itself is
|
||||
factored out as `RemoveInjection` so it is unit-testable without a filesystem.
|
||||
**Gap:** no test executes it yet, so this stays `In Progress` rather than
|
||||
`Done` — there is no test project in this repo.
|
||||
|
||||
### JR-023 — A hard dependency Jellyfin cannot resolve
|
||||
|
||||
**Jellyfin has no plugin dependency mechanism.** A manifest cannot declare that
|
||||
another plugin is required, and nothing will install one. File Transformation
|
||||
documents only an end-user repository URL and a reflection integration for plugin
|
||||
authors; there is no NuGet-style dependency to take.
|
||||
|
||||
**jRay must not bundle the assembly.** A bundled copy would sit in a different
|
||||
`AssemblyLoadContext` from the real one — precisely the failure the reflection
|
||||
integration exists to avoid — on top of licensing and version skew. The
|
||||
dependency is satisfied by the user installing the real plugin.
|
||||
|
||||
So:
|
||||
|
||||
1. **Detect at startup and say so** — log a warning naming the plugin and its
|
||||
install URL, and disable only the overlay. Every other feature works.
|
||||
2. **Surface it where it can be acted on** — the configuration page shows
|
||||
dependency status: satisfied, or missing with the manifest URL
|
||||
`https://www.iamparadox.dev/jellyfin/plugins/manifest.json` and a one-line
|
||||
instruction. A warning only in the server log is one nobody reads.
|
||||
3. **State it as a prerequisite in install docs**, before the jRay install step.
|
||||
|
||||
Optionally, publish jRay through a repository manifest that also lists File
|
||||
Transformation, so one repository URL surfaces both. This is not a dependency
|
||||
mechanism; it removes a step and the chance of installing the wrong thing.
|
||||
|
||||
**Current:** all three implemented. Startup detection and registration, a warning
|
||||
naming the plugin and its install URL, and a status banner on the configuration
|
||||
page fed by `GET /Plugins/JRay/Status/Dependencies` — satisfied, or missing with
|
||||
the manifest URL and what to do with it. The README now states the dependency
|
||||
before the install step rather than after it. **Gap:** no test executes the
|
||||
detection branch, so this stays `In Progress`; the config-page half is T4 and
|
||||
verifiable only against a live server.
|
||||
|
||||
### JR-024 — Names render as text, never markup
|
||||
|
||||
Every string that reaches the overlay — actor names above all — is rendered as
|
||||
text. With §6 the source of those strings may be a third-party server, and the
|
||||
server spec §5a names this the single most important client-side control,
|
||||
because it holds even when every other check is bypassed.
|
||||
|
||||
It is stated here as a plugin requirement because the server cannot enforce it
|
||||
and the DOM is jRay's.
|
||||
|
||||
**Current:** satisfied. [`Web/jray-overlay.js`](Jellyfin.Plugin.JRay/Web/jray-overlay.js)
|
||||
uses `textContent` throughout — no `innerHTML`, no `insertAdjacentHTML`. **Gap:**
|
||||
nothing behavioural. It holds today by construction rather than by rule, which
|
||||
is what the static check exists to keep true once §6 makes remote strings
|
||||
reachable.
|
||||
|
||||
---
|
||||
|
||||
## 6. Manifest exchange client — JR-025 … JR-037
|
||||
|
||||
The wire format, tiers and server behaviour are specified in
|
||||
[`../JRay-public-server/SPEC.md`](../JRay-public-server/SPEC.md). **This section
|
||||
owns the client half**, which previously lived in that document's §9 — an
|
||||
inversion, since those are obligations on this repo.
|
||||
|
||||
A Jmanifest is this truth file plus a portable identity block and a cut
|
||||
fingerprint, minus the installation-local fields.
|
||||
|
||||
### JR-025, JR-026, JR-037 — Server list and resolution
|
||||
|
||||
The plugin queries a configured **ordered list**, not a single URL. Per server:
|
||||
`Url`, `Name`, `Token` (contribution only), `Enabled`, `AllowContribute`,
|
||||
`TrustLevel` (`Full` / `FetchOnly`). A community entry ships pre-configured but
|
||||
**disabled**.
|
||||
|
||||
**First acceptable wins** — servers are tried in order, and the first result
|
||||
clearing the configured tier is taken. Order *is* the user's trust ranking, made
|
||||
explicit. Best-match-across-all would multiply egress and leak the library to
|
||||
more parties for a gain the ordering already expresses.
|
||||
|
||||
**For a series, first-match applies per episode** (JR-026): fetch the bundle from
|
||||
server 1, then query server 2 only for what is still missing. Series are commonly
|
||||
split across sources, and this is where multiple servers earn their keep.
|
||||
|
||||
**Failure isolation** (JR-037): an unreachable or failing server is skipped after
|
||||
a short timeout (5 s connect, 30 s read) and marked failed with exponential
|
||||
backoff. One dead server must never stall a library sweep; failures surface
|
||||
per-server in the config page.
|
||||
|
||||
**Current:**
|
||||
[`ManifestServer`](Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs) and
|
||||
[`PluginConfiguration`](Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs)
|
||||
model all of this. **Gap:** nothing consumes them — there is no HTTP client.
|
||||
|
||||
### JR-027 … JR-029 — Every server is untrusted
|
||||
|
||||
Everything in the server spec §5a is a property of a *correctly operated* server.
|
||||
Pointing the plugin at an arbitrary URL inherits none of it. jRay therefore
|
||||
re-applies client-side what a server applies on upload, **including for the
|
||||
default server**:
|
||||
|
||||
- **JR-027 — validate on receipt.** Downloaded manifests go through the same
|
||||
strict schema as uploads: unknown fields rejected, sizes capped, and windows
|
||||
bounds-checked against the item's real runtime. A manifest is never trusted
|
||||
because a server served it.
|
||||
- **JR-028 — size caps enforced while streaming**, so an unbounded body is
|
||||
aborted rather than buffered. 2 MiB single manifest, 25 MiB bundle.
|
||||
- **JR-029 — HTTPS required** for non-loopback servers, with certificate
|
||||
validation never disabled. A plaintext server would let any intermediary
|
||||
rewrite actor overlays.
|
||||
- **`TrustLevel: FetchOnly`** — the default for user-added servers — accepts
|
||||
manifests but never contributes and never sends inventory beyond the single
|
||||
item queried.
|
||||
|
||||
The honest framing for the config page: *adding a third-party server means
|
||||
trusting its operator not to serve you deliberately wrong actor data.* The
|
||||
controls above bound the damage to bad overlay content; they cannot make wrong
|
||||
data right.
|
||||
|
||||
### JR-030 — Offsets are applied before storage
|
||||
|
||||
When a match carries a non-zero `offset` (the `audio` tier — §8), the plugin
|
||||
**must** add it to every scene window before storing.
|
||||
|
||||
**The stored truth file is always in the local file's own timebase.** This is
|
||||
what keeps the offset out of the read path entirely: `Timeline`, `jray?t=` and
|
||||
the overlay never learn that an offset existed. An offset applied at read time
|
||||
would have to be applied identically in three places and would be wrong in the
|
||||
fourth.
|
||||
|
||||
### JR-031, JR-032 — Endpoints
|
||||
|
||||
Mirroring the existing Truth and Tasks controllers:
|
||||
|
||||
| Route | Purpose |
|
||||
|---|---|
|
||||
| `POST /Plugins/JRay/Items/{itemId}/Fetch` | Resolve across servers in order; on a match at or above the configured tier, apply JR-030 and store via the managed store |
|
||||
| `POST /Plugins/JRay/Series/{seriesId}/Fetch` | Bundle fetch with per-episode gap-filling |
|
||||
| `GET /Plugins/JRay/Servers/Status` | Per-server reachability and last error, for the config page |
|
||||
| `POST /Plugins/JRay/Items/{itemId}/Identify` | Compute the audio signature and search by content, for items of unknown providence |
|
||||
|
||||
**JR-032: `Identify` never stores automatically.** It returns candidate titles
|
||||
with scores and offsets; storing one is a separate confirmation step. Content
|
||||
identification is a guess about what a file *is*, and a wrong guess silently
|
||||
attaches another film's cast to it.
|
||||
|
||||
### JR-033 — Scheduled sweep
|
||||
|
||||
A scheduled task walks items with no truth data and attempts a fetch, reusing the
|
||||
`Tasks/Pending` backlog logic — including its policy rules — and the **batch**
|
||||
`exists` endpoint, so a sweep is a handful of requests per server rather than one
|
||||
per item.
|
||||
|
||||
### JR-034, JR-035 — Contribution
|
||||
|
||||
On a `PUT .../Truth` from a local worker, if contribution is enabled: strip
|
||||
`movie` and `jellyfin_id`, attach identity from the item's `ProviderIds` and its
|
||||
measured runtime, and `POST` to each contribute-enabled server. For a series,
|
||||
batch into one bundle upload rather than per-episode posts.
|
||||
|
||||
**Stripping is a requirement, not hygiene.** `movie` leaks the contributor's
|
||||
directory layout and `jellyfin_id` is a GUID from their database — meaningless
|
||||
elsewhere and mildly identifying. The server rejects both, but the plugin must
|
||||
not send them in the first place.
|
||||
|
||||
**Contribution is never fanned out.** A manifest goes only to servers with
|
||||
`AllowContribute` set, each an explicit choice.
|
||||
|
||||
**JR-035:** uploads set `Expect: 100-continue`, so a server rejecting on size or
|
||||
auth does so before the body is transmitted. This matters most for bundles, where
|
||||
a rejected upload would otherwise push tens of MiB pointlessly.
|
||||
|
||||
### JR-036 — Match tier is the user's dial
|
||||
|
||||
The configured minimum tier (`exact` / `audio` / `runtime` / `loose`) gates what
|
||||
may be stored. A `loose` match — runtimes within ±30 s — is plausibly a different
|
||||
trim of the same cut, so it is **surfaced as a caveat in the UI**, not applied
|
||||
silently. Per JR-010 the tier is recorded with the stored truth, which is what
|
||||
makes surfacing it possible after the fetch has finished.
|
||||
|
||||
**Current:** `MinimumMatchTier` exists in configuration, defaulting to `runtime`.
|
||||
**Gap:** nothing reads it; no caveat is displayed.
|
||||
|
||||
---
|
||||
|
||||
## 7. Egress and privacy — JR-038 … JR-041
|
||||
|
||||
Contribution reveals to a server operator that some instance holds a given title.
|
||||
Fetching reveals the same. That is inherent to the exchange — which is why the
|
||||
requirements here bound it rather than claim to remove it.
|
||||
|
||||
- **JR-038 — opt-in, off by default.** Manifest sharing, contribution and audio
|
||||
signatures are three separate switches, all default off, and the pre-configured
|
||||
community server ships **disabled**. No traffic leaves an installation until an
|
||||
admin acts.
|
||||
- **JR-039 — no library-wide inventory in one request.** The batch `exists`
|
||||
endpoint is capped at 100 items and sweeps are paced. A single request
|
||||
enumerating a library is a fingerprint of it, which is the thing PR-005 exists
|
||||
to prevent.
|
||||
- **JR-040 — the config page says plainly that each configured server multiplies
|
||||
the exposure.** First-match resolution limits it — later servers are queried
|
||||
only for what earlier ones lacked — and that is worth stating too.
|
||||
- **JR-041 — the plugin never touches gallery data.** No reference faces, no
|
||||
embeddings, fetched or stored or transmitted. There is no such code path and
|
||||
there must not be one (SR-005). Verified by static check, mirroring the
|
||||
server's UR-012.
|
||||
|
||||
**Current:** JR-038 holds — every switch defaults off. JR-041 holds vacuously,
|
||||
there being no gallery code. **Gap:** JR-039 and JR-040 are unimplemented,
|
||||
alongside the exchange client itself.
|
||||
|
||||
---
|
||||
|
||||
## 8. Audio signature — JR-042 … JR-045
|
||||
|
||||
A content-derived fingerprint from the centre of a media file, used to identify a
|
||||
file of unknown providence and to recover the time offset between differently
|
||||
trimmed releases of the same cut. Construction is specified in
|
||||
[`../JRay-public-server/SPEC.md` §3](../JRay-public-server/SPEC.md) and must be
|
||||
implemented **exactly**:
|
||||
|
||||
1. Decode a 120 s window centred on the midpoint (`runtime/2 ± 60 s`) — avoiding
|
||||
logos and cold opens at the head, credits at the tail.
|
||||
2. Downmix to mono, resample to 11025 Hz.
|
||||
3. STFT: 4096-sample frame, 1024-sample hop (~93 ms, ~1290 frames), Hann window.
|
||||
4. Log-magnitude spectrum over 300–3000 Hz.
|
||||
5. 32 logarithmically spaced bins; record peak-bin index plus a 2-bit energy
|
||||
class.
|
||||
6. One byte per frame → ~1290-byte array, base64-encoded.
|
||||
|
||||
**JR-042 — no new dependency.** FFmpeg performs decode, downmix and resample,
|
||||
using the binary Jellyfin already ships, reached via `IMediaEncoder.EncoderPath`
|
||||
from `MediaBrowser.Controller.MediaEncoding`. The plugin implements only a small
|
||||
fixed FFT and bin-peak extraction.
|
||||
|
||||
**JR-043 — bit-exactness is verified, not assumed.** The pipeline computes this
|
||||
signature too (extraction `IR-004`), deliberately: files never processed locally
|
||||
still get one from the plugin. Two independent implementations of one fingerprint
|
||||
are only useful if they agree exactly, so a **golden-vector fixture is shared
|
||||
between the two repos** — a short WAV and its expected signature, committed in
|
||||
both. It is CPU-only DSP, which is why this cross-repo check can be a binding CI
|
||||
test rather than an aspiration. Extraction's counterpart is `IR-005`.
|
||||
|
||||
**JR-044 — media shorter than 120 s.** The window underflows, so **no signature
|
||||
is emitted and no sync offset is applied**. Such items fall back to the runtime
|
||||
and exact tiers, which is adequate: a 90-second extra is not content whose cut
|
||||
alignment matters. Both producers must apply the identical rule, or they diverge
|
||||
on exactly the short items most likely to be misidentified. Extraction's
|
||||
counterpart is `IR-007`.
|
||||
|
||||
**JR-045 — the signature carries its own `v1:` prefix**, separate from
|
||||
`schema_version`. Emit and honour it, so a future change to the DSP chain is
|
||||
*detectable* rather than silently producing non-matching signatures. Extraction's
|
||||
counterpart is `IR-008`.
|
||||
|
||||
Matching — sliding ±600 frames (≈±56 s), scoring the fraction of overlapping
|
||||
frames whose peak bin matches — is a **consumer** concern and belongs to this
|
||||
plugin. Offsets are applied client-side per JR-030; manifests are never
|
||||
rewritten.
|
||||
|
||||
**Current:** `ComputeAudioSignatures` exists as a configuration switch. **Gap:**
|
||||
entire requirement, both computation and matching.
|
||||
|
||||
---
|
||||
|
||||
## 9. Human-in-the-loop association — JR-046
|
||||
|
||||
*Proposed. See [system spec](scripts/vendor/jray-project/SPEC.md) §4, which owns the design.*
|
||||
|
||||
The pipeline produces **unidentified tracks** — a face that is genuinely someone,
|
||||
sustained across many frames, that the gallery cannot name. A user watching the
|
||||
film usually knows exactly who it is. jRay's contribution is the review UI: show
|
||||
a cluster's context crops, let the user pick from the title's cast or search
|
||||
TMDB, and record the association for extraction to ingest into the local gallery.
|
||||
|
||||
**The unit of review is a person, not a track.** Unknown tracks are clustered
|
||||
upstream (`AR-021`), so the question is "who is this person, who appears in these
|
||||
twelve places?" rather than twelve disconnected questions. One answer resolves
|
||||
the cluster.
|
||||
|
||||
Deliberately left as a single `TBD` row rather than decomposed. It depends on
|
||||
extraction `AR-021`/`AR-022` landing, and on **system open question 2** — whether
|
||||
unidentified presence is published in the truth file at all, which determines
|
||||
whether this UI's work queue arrives with the truth data or needs a separate
|
||||
channel. Decomposing now would fix an interface against an undecided upstream.
|
||||
|
||||
---
|
||||
|
||||
## 10. Client: pushing results from a remote worker
|
||||
|
||||
Reference material for worker authors; the requirements are JR-009 and JR-015.
|
||||
|
||||
**1. Authenticate.** Create an Administrator API key (Dashboard → API Keys) and
|
||||
send it as `X-Emby-Token: <key>` or
|
||||
`Authorization: MediaBrowser Token="<key>"`.
|
||||
|
||||
**2. Find work.** Poll `GET /Plugins/JRay/Tasks/Pending?limit=10` rather than
|
||||
walking the library and checking each item.
|
||||
|
||||
**3. Resolve the item id.** The push endpoint is keyed by Jellyfin item GUID, not
|
||||
path:
|
||||
|
||||
```
|
||||
GET /Items?Recursive=true&Fields=Path&IncludeItemTypes=Movie,Episode
|
||||
```
|
||||
|
||||
(use `&ParentId=<library-id>` to narrow the search if the library is large).
|
||||
Each returned item DTO has `Id` (the GUID) and `Path`. Match `Path` against
|
||||
the absolute path of the file you just processed — note this requires the
|
||||
worker to see the file at the *same path* Jellyfin does (same mount/share);
|
||||
translate paths first if the worker mounts the library elsewhere.
|
||||
Match `Path` against the file you processed — which requires the worker to see
|
||||
the file at the *same path* Jellyfin does; translate first if it mounts the
|
||||
library elsewhere. The mapping is stable until the file moves, so cache
|
||||
`path -> itemId` and re-resolve only on a miss.
|
||||
|
||||
This mapping is stable until the file is moved/re-scanned, so the worker
|
||||
should cache `path -> itemId` and only re-resolve on a cache miss.
|
||||
**4. Push.** `PUT /Plugins/JRay/Items/{itemId}/Truth` with the truth file body.
|
||||
`204` stored (cache invalidated immediately), `400` unsupported
|
||||
`schema_version`, `401`/`403` key missing or not an administrator. The `PUT` is
|
||||
idempotent, so retrying on a network error is safe.
|
||||
|
||||
### 3. Push the truth file
|
||||
**5. Optionally remove.** `DELETE /Plugins/JRay/Items/{itemId}/Truth` always
|
||||
returns `204`; the item falls back to its sidecar on the next read.
|
||||
|
||||
```
|
||||
PUT /Plugins/JRay/Items/{itemId}/Truth
|
||||
Content-Type: application/json
|
||||
---
|
||||
|
||||
<truth file JSON, schema_version 1, as produced by result_sink_node>
|
||||
```
|
||||
## 11. Open questions
|
||||
|
||||
- `204 No Content` — stored. Takes effect immediately (any cached read for
|
||||
this item is invalidated server-side).
|
||||
- `400 Bad Request` — `schema_version` is not `1`.
|
||||
- `401`/`403` — API key missing or not an administrator.
|
||||
|
||||
The `PUT` is idempotent (replaces any existing managed truth for the item),
|
||||
so the worker can safely retry on network errors.
|
||||
|
||||
### 4. (Optional) Remove pushed data
|
||||
|
||||
```
|
||||
DELETE /Plugins/JRay/Items/{itemId}/Truth
|
||||
```
|
||||
|
||||
Always returns `204`. The item falls back to a sidecar `Movie.jray.json` (if
|
||||
any) on the next read.
|
||||
|
||||
## Web client pause overlay
|
||||
|
||||
Since Jellyfin has no plugin hook for player UI, JRay injects
|
||||
`<script defer src="/Plugins/JRay/ClientScript"></script>` into the web
|
||||
client's `index.html` on startup (idempotent, marked with
|
||||
`<!-- jray-overlay -->`). The injected script listens for the video player's
|
||||
pause event, calls `jray?t=` for the current item and timestamp, and renders
|
||||
a small overlay listing on-screen actors. This can be disabled via the
|
||||
plugin's "Enable pause overlay" setting, which also removes the injected
|
||||
script.
|
||||
1. **`sample_fps`, `gallery_size` and `pipeline_version` move under
|
||||
`extraction.*` in JR-002.** This aligns the truth file with the Jmanifest's
|
||||
block of the same name, and the bump is breaking regardless. It is a change
|
||||
this spec proposes rather than one inherited from SR-003's list — confirm, or
|
||||
keep them top-level.
|
||||
2. **Does `route` belong in the `jray?t=` envelope?** JR-013 says the response is
|
||||
extensible and JR-005 says presentation must not over-claim. Exposing belief
|
||||
and route would let the overlay caveat a weak claim, but invites a UI that
|
||||
shows a number to a viewer who cannot act on it.
|
||||
3. **System open question 2 — unidentified presence.** If published, the overlay
|
||||
could show "unidentified person" and JR-046 gets its queue from the truth file
|
||||
directly. jRay is the consumer that would have to display it, so this repo has
|
||||
a position to state.
|
||||
4. **Test-ID namespacing.** `UT-nnn`/`IT-nnn` are per-component registers, so
|
||||
`UT-001` will exist in both this repo and `scene-actor-extraction`. Fine while
|
||||
the gate runs per repo; ambiguous the moment a rollup spans them.
|
||||
|
||||
+6
-1
@@ -19,4 +19,9 @@ dotnet_configuration: "Release"
|
||||
dotnet_framework: "net9.0"
|
||||
project: "Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj"
|
||||
changelog: >
|
||||
Initial scaffold
|
||||
The pause overlay is now served through the File Transformation plugin, which
|
||||
rewrites the web client's index.html as it is sent. JRay no longer edits that
|
||||
file on disk and there is no fallback: without File Transformation the overlay
|
||||
is disabled and every other feature works normally. Any stale on-disk patch
|
||||
left by an earlier JRay is removed on startup, and the plugin's configuration
|
||||
page now reports whether the dependency is satisfied.
|
||||
|
||||
@@ -0,0 +1,269 @@
|
||||
# jRay — requirements register
|
||||
|
||||
Stable IDs for every requirement in [`../SPEC.md`](../SPEC.md), which holds the
|
||||
prose. This file is the **authoritative list**; the CI gate reads its
|
||||
denominators from here (see [`../scripts/vendor/jray-project/SPEC.md`](../scripts/vendor/jray-project/SPEC.md) §6).
|
||||
|
||||
**IDs are permanent.** A withdrawn requirement is marked `Withdrawn` and its
|
||||
number is never reused — renumbering is what produces orphan TRACES tags. This
|
||||
register replaces the earlier section-numbering of `SPEC.md`, which gave the
|
||||
plugin no way to be traced to and left it outside the chain entirely.
|
||||
|
||||
Tag code with `// TRACES: JR-012 | SR-002`.
|
||||
|
||||
| Type | Scope |
|
||||
|---|---|
|
||||
| `JR` | Everything this plugin does — truth format, API, overlay, exchange client |
|
||||
| `UT` / `IT` | Unit / integration tests |
|
||||
|
||||
`JR` is flat rather than split by theme. The plugin is one deployable with one
|
||||
audience, and the thematic grouping lives in the section headings below, where it
|
||||
costs nothing and cannot go stale against a prefix.
|
||||
|
||||
Status: `Done` · `In Progress` · `Planned` · `TBD` · `Withdrawn`
|
||||
|
||||
---
|
||||
|
||||
## Truth-file format (JR-001 … JR-007)
|
||||
|
||||
jRay **owns** this format ([system spec](../scripts/vendor/jray-project/SPEC.md) §1); extraction is the
|
||||
producer and the public server carries a derived envelope. Changes are
|
||||
coordinated `schema_version` bumps (SR-003).
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-001 | The truth-file format is normatively defined here; other repos reference it rather than restating it | SR-003 | High | In Progress |
|
||||
| JR-002 | `schema_version: 2` shape — `extraction.*` provenance block, `cut.*` block, `scenes` as objects carrying belief and route | SR-003 | High | Planned |
|
||||
| JR-003 | Reject an unknown `schema_version`, never guess. **Flag day: v2 only**, no dual-accept | SR-003 | High | Planned |
|
||||
| JR-004 | A window is a **scene-membership claim**, not a recognition event — never reinterpreted, merged, split or trimmed | **SR-002** | High | Planned |
|
||||
| JR-005 | Query semantics: actor present at `t` if any window contains `t`; presentation must not assert instantaneous visibility | **SR-002** | High | In Progress |
|
||||
| JR-006 | Read path holds up under **numerous** windows — no assumption of a handful of long ones | SR-002 | Medium | Planned |
|
||||
| JR-007 | Identity is public identifiers: prefer `jellyfin_id` locally, else resolve `imdb_id`/`tmdb_id` against the item's People `ProviderIds` | SR-001 | High | Done |
|
||||
|
||||
## Truth-data sources and precedence (JR-008 … JR-011)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-008 | Discover a sidecar truth file beside the media, by configurable suffix | PR-001 | High | Done |
|
||||
| JR-009 | Accept truth data pushed by a remote worker (`PUT`/`DELETE`), admin key | PR-004 | High | Done |
|
||||
| JR-010 | Precedence: managed truth (pushed **or** fetched) overrides a sidecar; provenance is recorded so the UI can distinguish the three sources | PR-001 | High | In Progress |
|
||||
| JR-011 | Loaded truth is cached; any write invalidates the item's cache entry immediately | PR-001 | Medium | Done |
|
||||
|
||||
## Read API (JR-012 … JR-014)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-012 | `GET .../Timeline` returns the full truth file for an item | PR-001 | High | Done |
|
||||
| JR-013 | `GET .../jray?t=` returns an **extensible** context envelope; consumers ignore unknown keys | PR-001 | High | Done |
|
||||
| JR-014 | Authorisation: reads need an authenticated user, admin routes need the Administrator role, only `ClientScript` is anonymous | PR-004 | High | Done |
|
||||
|
||||
## Work discovery, policy and coverage (JR-015 … JR-019)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-015 | `Tasks/Pending` serves a random sample of items with no truth data, so pollers spread across the backlog without server-side task state | PR-003 | High | Done |
|
||||
| JR-016 | Prioritise/ignore rules scoped `Genre` / `Series` / `Item`; **most specific wins**; scope+value is the unique key | PR-003 | Medium | Done |
|
||||
| JR-017 | Rules steer **work discovery only** — never the overlay or the read endpoints | PR-003 | Medium | Done |
|
||||
| JR-018 | Coverage report by media type and genre; ignored items leave the percent-done denominator rather than dragging it down | PR-003 | Medium | Done |
|
||||
| JR-019 | Picker endpoints (genres, series, item search) populate the rule editor | PR-003 | Low | Done |
|
||||
|
||||
## Player overlay (JR-020 … JR-024)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-020 | Pause overlay: injected client script queries `jray?t=` and renders the scene's cast | **PR-001** | High | Done |
|
||||
| JR-021 | **jRay never injects into `index.html` on disk.** File Transformation is a hard dependency; there is no on-disk fallback. The only permitted write is JR-022's removal | PR-004 | High | **Done** |
|
||||
| JR-022 | Migration: remove any on-disk patch left by an earlier jRay, identified by the `<!-- jray-overlay -->` marker | PR-004 | High | In Progress |
|
||||
| JR-023 | Absent the dependency, disable **only** the overlay and say so in the log and the config page; never bundle the assembly | PR-004 | Medium | In Progress |
|
||||
| JR-024 | Actor names and all server-supplied strings render as **text, never markup** | SR-004 | High | Done |
|
||||
|
||||
## Manifest exchange client (JR-025 … JR-037)
|
||||
|
||||
Plugin-side requirements for the exchange specified in
|
||||
[`../../JRay-public-server/SPEC.md`](../../JRay-public-server/SPEC.md) §9. The
|
||||
wire format is the server's; **the client's obligations are jRay's**, and belong
|
||||
in this register rather than in the server's spec.
|
||||
|
||||
The server's register already anticipates this: its `UR-007` is recorded as
|
||||
having "no server-side test and cannot have one — it is a requirement on the
|
||||
plugin", to be cross-referenced from the plugin's register once one exists. This
|
||||
is that register, and `JR-025` is that row. `UR-007` should now point here and
|
||||
stay `In Progress` until `JR-025` is `Done`.
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-025 | Query an **ordered list** of servers; first result clearing the configured tier wins — **satisfies `JRay-public-server` UR-007** | PR-006 | High | In Progress |
|
||||
| JR-026 | For a series, first-match applies per **episode** — later servers are queried only for the episodes earlier ones lacked | PR-006 | Medium | Planned |
|
||||
| JR-027 | Treat **every** server as untrusted, including the default: re-validate on receipt against the strict upload schema, bounds-check windows against the item's real runtime | SR-004 | High | Planned |
|
||||
| JR-028 | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB bundle — aborting rather than buffering | SR-004 | High | Planned |
|
||||
| JR-029 | HTTPS required for non-loopback servers; certificate validation must not be disabled | SR-004 | High | Planned |
|
||||
| JR-030 | Apply an `audio`-tier `offset` to **every** window before storing — stored truth is always in the local file's timebase, so read paths need no offset awareness | SR-003 | High | Planned |
|
||||
| JR-031 | Fetch endpoints: item fetch, series bundle fetch, per-server status, content identify | PR-006 | High | Planned |
|
||||
| JR-032 | Identify is **never automatic** — storing a candidate is a separate confirmation step | PR-006 | Medium | Planned |
|
||||
| JR-033 | Scheduled sweep over items lacking truth data, using the **batch** `exists` endpoint | PR-006 | Medium | Planned |
|
||||
| JR-034 | Contribution strips `movie` and `jellyfin_id`, attaches identity from `ProviderIds` plus measured runtime, and posts **only** to contribute-enabled servers — never fanned out | PR-005 | High | Planned |
|
||||
| JR-035 | Uploads set `Expect: 100-continue`, so a rejection lands before a bundle body is transmitted | PR-006 | Low | Planned |
|
||||
| JR-036 | Minimum accepted match tier is configurable; a `loose` match surfaces as a caveat rather than being applied silently | PR-006 | Medium | In Progress |
|
||||
| JR-037 | A server that is unreachable or failing is skipped on a short timeout with backoff; one dead server never stalls a sweep | PR-006 | Medium | Planned |
|
||||
|
||||
## Egress and privacy (JR-038 … JR-041)
|
||||
|
||||
`PR-005` had **no software row in any repo** — it was held structurally, by
|
||||
SR-004 and GR-005 both being prohibitions. jRay is the component that actually
|
||||
performs egress, so these are the rows that make it verifiable rather than merely
|
||||
preserved.
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-038 | Every exchange feature is **opt-in and off by default**, including the pre-configured community server | **PR-005** | High | Done |
|
||||
| JR-039 | No library-wide inventory in one request: batch `exists` capped at 100 items, sweeps paced | **PR-005** | High | Planned |
|
||||
| JR-040 | The config page states plainly that **each configured server multiplies the exposure** | **PR-005** | Medium | Planned |
|
||||
| JR-041 | The plugin never fetches, stores, or transmits gallery data — reference faces or embeddings. It has no gallery code path at all | **SR-005** | High | Done |
|
||||
|
||||
## Audio signature (JR-042 … JR-045)
|
||||
|
||||
Mirror-image of extraction `IR-004`/`IR-005`/`IR-007`/`IR-008`. Both producers
|
||||
must agree **bit-for-bit**, so each obligation is stated on both sides rather
|
||||
than assumed to be inherited.
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-042 | Compute the signature **exactly** per server spec §3, using the FFmpeg binary Jellyfin already ships via `IMediaEncoder.EncoderPath` — no new dependency | SR-003 | Medium | Planned |
|
||||
| JR-043 | Golden-vector fixture **shared with the extraction repo**, proving the two implementations are bit-exact | SR-003 | High | Planned |
|
||||
| JR-044 | Media shorter than 120 s: emit no signature and apply no sync offset — identical rule in both producers | SR-003 | Low | Planned |
|
||||
| JR-045 | Emit and honour the signature's own `v1:` prefix, so a DSP change is detectable rather than silently non-matching | SR-003 | Low | Planned |
|
||||
|
||||
## Human-in-the-loop association (JR-046)
|
||||
|
||||
| ID | Requirement | Traces to | Priority | Status |
|
||||
|---|---|---|---|---|
|
||||
| JR-046 | Review UI for unidentified track clusters: show context crops, pick from the title's cast or search TMDB, record the association | [system §4](../scripts/vendor/jray-project/SPEC.md) | Medium | **TBD** |
|
||||
|
||||
Deliberately a single placeholder row rather than a decomposed set. It depends on
|
||||
extraction `AR-021`/`AR-022` landing, and on system open question 2 (whether
|
||||
unidentified presence is published at all) — decomposing it now would fix an
|
||||
interface against an undecided upstream.
|
||||
|
||||
---
|
||||
|
||||
## Verification strategy
|
||||
|
||||
**CI is an Intel N100** ([system spec](../scripts/vendor/jray-project/SPEC.md) §6). Unlike the extraction
|
||||
pipeline this costs jRay almost nothing: the plugin is CPU-only managed code, and
|
||||
every requirement above except the live-integration ones is executable in CI.
|
||||
|
||||
| Tier | Runs in CI | What it covers |
|
||||
|---|---|---|
|
||||
| **T1 — Unit** | Yes | Parsing, precedence, policy resolution, coverage arithmetic, offset application, audio DSP, schema rejection |
|
||||
| **T2 — Host integration** | Yes | Controllers and authorisation against a test host with a faked `ILibraryManager` |
|
||||
| **T4 — Live** | **No** | Real Jellyfin + File Transformation + web client; real manifest server round-trip |
|
||||
| **static** | Yes | Grep/analyzer checks — e.g. no injection path into `index.html` (JR-021) |
|
||||
|
||||
**T3 is deliberately unused.** The gate's `CI_EXECUTABLE_TIERS` treats T1/T2/T3
|
||||
as CI-runnable and T4 as not, which is right for extraction (where T3 is slow CPU
|
||||
inference and T4 is GPU). jRay has only two CI tiers and one live tier, so its
|
||||
non-CI tier is numbered **T4** to match that shared constant rather than
|
||||
renumbering it. Calling jRay's live tier "T3" would make the gate count
|
||||
live-only requirements as covered — the exact class of error the 158% coverage
|
||||
bug belongs to.
|
||||
|
||||
**There is no test project today.** That is the single largest gap in this
|
||||
register: 46 requirements, zero `UT`/`IT` IDs, so measured coverage will open at
|
||||
zero and every `Done` above rests on inspection rather than evidence.
|
||||
|
||||
### Per-requirement verification plan
|
||||
|
||||
| ID | Tier | Test asserts | Edge cases to cover |
|
||||
|---|---|---|---|
|
||||
| JR-001 | static | Other repos' specs link here rather than restating the schema | A second copy of the schema anywhere is the failure |
|
||||
| JR-002 | T1 | A v2 file round-trips; `scenes` objects retain belief and route | Window with belief exactly at the ownership threshold; all three route values |
|
||||
| JR-003 | **T1** | `schema_version` 1 and 3 are both **rejected**, not coerced | Missing field entirely; non-integer value |
|
||||
| JR-004 | T1 | Windows are stored and served byte-identical to input | Adjacent windows that "look" mergeable must **not** merge |
|
||||
| JR-005 | T1 | `t` exactly on `start` and on `end` are both present | Zero-length window; overlapping windows for one actor |
|
||||
| JR-006 | T1 | Query cost is acceptable with 10³ windows on one actor | Sorted-window assumption stated and tested |
|
||||
| JR-007 | T1 | `jellyfin_id` preferred; falls back to provider ids | All three ids empty → actor still displayable by name |
|
||||
| JR-008 | T1 | Sidecar path derived from the item path plus the configured suffix | Item with no path; suffix changed at runtime |
|
||||
| JR-009 | T2 | `PUT` stores, `DELETE` removes, both admin-only | `DELETE` on an item with no managed truth is still `204` |
|
||||
| JR-010 | T1 | Managed overrides sidecar; provenance survives | Fetched and pushed truth for the same item |
|
||||
| JR-011 | T1 | A write invalidates the cached entry immediately | Read, push, read again within the cache window |
|
||||
| JR-012 | T2 | Returns the file, or `404` when no source has data | Sidecar present but unparseable |
|
||||
| JR-013 | T2 | Envelope shape is stable; extra keys are additive | Item with truth data but no actor present at `t` |
|
||||
| JR-014 | T2 | Anonymous request to each admin route is refused | Authenticated non-admin on an admin route |
|
||||
| JR-015 | T2 | Sample excludes covered items and clamps `limit` | `limit` of 0 and of 1000; library of missing-path ghosts |
|
||||
| JR-019 | T2 | Pickers return `{value,label}`; empty search returns `[]` | Two episodes named "Pilot" — labels must disambiguate |
|
||||
| JR-020 | **T4** | Overlay appears on pause and lists the scene cast | Live web client only |
|
||||
| JR-016 | T1 | Item beats Series beats Genre | Prioritised series inside an ignored genre — the case that motivated the rule |
|
||||
| JR-017 | **T1** | An ignored item still serves its overlay | Rule added after truth data exists |
|
||||
| JR-018 | T1 | `covered / (total - ignored)` | Item carrying two genres counts in both rows |
|
||||
| JR-021 | **static** | No code path *adds* the script tag to `index.html` | `scripts/checks/no-index-injection.sh`. Removal (JR-022) is the one permitted write, so the check is on injection, not on writing. Verified to **fail** on a reintroduced `Apply()` and on reintroduced `ReplaceLast` injection, not merely to pass today |
|
||||
| JR-022 | T1 | A marked legacy patch is removed; unmarked content untouched | Foreign plugin's injection left intact |
|
||||
| JR-023 | T1 + **T4** | Absent dependency disables only the overlay | Detection unit-testable; config-page display is live |
|
||||
| JR-024 | T1 | A name containing markup renders escaped | `<script>` in an actor name from a hostile server |
|
||||
| JR-025 | T1 | First result clearing the tier wins; disabled servers skipped | All servers fail; first server returns a below-tier match |
|
||||
| JR-026 | T1 | Server 2 queried only for episodes server 1 lacked | Bundle with a gap in the middle of a season |
|
||||
| JR-027 | T1 | Unknown field, oversized body, and out-of-range window each rejected | Window ending beyond the item's runtime |
|
||||
| JR-028 | T1 | Stream aborts past the cap rather than buffering | Server declaring a small length and sending more |
|
||||
| JR-029 | T1 | Plain `http` to a non-loopback host is refused | `http://localhost` allowed; `http://192.168.x` refused |
|
||||
| JR-030 | **T1** | Offset added to every window before storage | Negative offset; offset that would push a window below zero |
|
||||
| JR-031 | T2 | All four routes exist and are admin-only | — |
|
||||
| JR-032 | T1 | `Identify` returns candidates and stores nothing | A single high-confidence candidate still does not auto-store |
|
||||
| JR-033 | T1 | Sweep batches through `exists` and paces | Backlog smaller than one batch |
|
||||
| JR-034 | **T1** | `movie` and `jellyfin_id` absent from the upload body | Contribution attempted to a `FetchOnly` server must not send |
|
||||
| JR-035 | T1 | `Expect: 100-continue` set on uploads | — |
|
||||
| JR-036 | T1 | Below-tier match is not stored; `loose` is flagged | Tier configured to `exact` with only a `runtime` match available |
|
||||
| JR-037 | T1 | Failing server skipped, backoff grows | Every server failing must not hang the sweep |
|
||||
| JR-038 | **T1** | Every exchange switch defaults off; community server disabled | Fresh config object, no user input |
|
||||
| JR-039 | T1 | Batch never exceeds 100 items | Library of 10⁴ items produces a paced sweep |
|
||||
| JR-040 | **T4** | Config page states the per-server exposure | Manual review of copy |
|
||||
| JR-041 | **static** | No embedding or image field is parsed or stored | Grep-based, mirroring the server's UR-012 |
|
||||
| JR-042 | T1 | DSP chain matches the specified parameters exactly | Window, hop, band, bin count each asserted individually |
|
||||
| JR-043 | **T1** | Signature matches the shared golden vector **bit-for-bit** | Media < 120 s → no signature; identical result in both repos |
|
||||
| JR-044 | T1 | Media < 120 s yields no signature and no offset | Exactly 120 s — the boundary both repos must agree on |
|
||||
| JR-045 | T1 | `v1:` emitted; an unknown prefix is refused, not parsed | `v2:` signature from a future producer |
|
||||
| JR-046 | **TBD** | — | Undesigned; depends on AR-021/AR-022 and system open question 2 |
|
||||
|
||||
Three are worth singling out. **JR-021** and **JR-041** are static checks because
|
||||
both are requirements to *not do something*, and a prohibition is verified by
|
||||
absence, not by a passing test. **JR-043** is the cross-repo check: it is the only
|
||||
test in this repo whose fixture is shared with another, and it is CPU-only DSP,
|
||||
which is exactly why it can be the binding check rather than an aspiration.
|
||||
|
||||
---
|
||||
|
||||
## Running the gate
|
||||
|
||||
The shared extractor now takes the three things that vary per repo as arguments,
|
||||
so this repo needs **no fork of it** — there must only ever be one
|
||||
implementation:
|
||||
|
||||
```sh
|
||||
python3 scripts/vendor/jray-project/scripts/traceability/extract_traces.py \
|
||||
--root . \
|
||||
--requirements docs/requirements.md \
|
||||
--system-spec scripts/vendor/jray-project/SPEC.md \
|
||||
--types JR \
|
||||
--suffixes .cs,.js,.sh \
|
||||
--scan-roots Jellyfin.Plugin.JRay,scripts/checks \
|
||||
--format coverage
|
||||
```
|
||||
|
||||
`scripts/checks` is scanned so the static checks carry their own TRACES tags —
|
||||
an enforcement script is evidence for a requirement exactly as a unit test is.
|
||||
The scan root is `scripts/checks` and **not** `scripts`, because the latter would
|
||||
walk `scripts/vendor/jray-project` and harvest the `AR-nnn` examples in the
|
||||
extractor's own docstrings as orphan tags.
|
||||
|
||||
`--root` must be **absolute or `.`**; the scan roots resolve beneath it. Both the
|
||||
extractor and the system spec come from the submodule, so the only thing this
|
||||
repo supplies is its own register and the three per-repo arguments.
|
||||
|
||||
Refresh the pinned tooling with
|
||||
`git submodule update --remote scripts/vendor/jray-project`.
|
||||
|
||||
**Naming conflict to resolve.** The tool's header comment expects
|
||||
`jRay → UR/DR`. This register uses `JR`, decided deliberately: `JRay-public-server`
|
||||
already ships `UR-001…018` and `DR-001…014`, so a second repo using the same
|
||||
prefixes would make `UR-007` ambiguous across registers — and `UR-007` is
|
||||
precisely the ID the server's own register asks the plugin to cross-reference
|
||||
(see JR-025). Either the comment or this register is wrong; the comment is the
|
||||
cheaper of the two to change.
|
||||
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
# JR-021 — jRay never injects into index.html on disk.
|
||||
#
|
||||
# This is a requirement to *not do* something, so it is verified by absence.
|
||||
# A unit test cannot show that no code path writes the tag; a grep can.
|
||||
#
|
||||
# The prohibition is on injection, not on writing: JR-022's migration must write
|
||||
# to index.html in order to remove a legacy patch. So the check is for code that
|
||||
# *adds* the script tag, not for File.Write* generally.
|
||||
#
|
||||
# TRACES: JR-021 | PR-004
|
||||
set -euo pipefail
|
||||
|
||||
cd "$(dirname "$0")/../.."
|
||||
src="Jellyfin.Plugin.JRay"
|
||||
status=0
|
||||
|
||||
# The injection is "script tag + marker" written back to the file. The removal
|
||||
# path also names both, so match on the concatenation that builds a patched
|
||||
# document rather than on the constants themselves.
|
||||
if grep -rn --include='*.cs' -E '(ScriptTag|Injected)[[:space:]]*\+.*BodyClose|ReplaceLast|"</body>"[[:space:]]*,' "$src" \
|
||||
| grep -v 'FileTransformationRegistration.cs'; then
|
||||
echo "FAIL (JR-021): index.html injection logic found outside the File Transformation callback." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# WebClientPatchService is removal-only. Any write there must be the cleaned
|
||||
# document; a write of a *patched* one is the regression this guards.
|
||||
if grep -n -E 'WriteAllText\((?!.*cleaned)' -P "$src/Services/WebClientPatchService.cs" >/dev/null 2>&1; then
|
||||
echo "FAIL (JR-021): WebClientPatchService writes something other than the cleaned document." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
# The disk-patching entry point must not come back.
|
||||
if grep -rn --include='*.cs' -E '\bWebClientPatchService\.Apply\b' "$src"; then
|
||||
echo "FAIL (JR-021): the injecting Apply() entry point has been reintroduced." >&2
|
||||
status=1
|
||||
fi
|
||||
|
||||
if [ "$status" -eq 0 ]; then
|
||||
echo "OK (JR-021): no on-disk injection path."
|
||||
fi
|
||||
|
||||
exit "$status"
|
||||
+1
Submodule scripts/vendor/jray-project added at 041961c8c6
Reference in New Issue
Block a user