Requirements register, spec rewrite, and TRACES tags
jRay had no requirement IDs, so nothing in this repo could be traced to and the CI gate had no denominator to read. The other two components had already moved to registers; this brings the plugin level with them. Adds docs/requirements.md with 46 permanent JR-nnn IDs, each carrying a parent requirement, priority, status and verification tier, plus a per-requirement verification plan. JR is flat rather than split by theme: the plugin is one deployable with one audience, and JRay-public-server already ships UR/DR, so a second repo using those prefixes would make UR-007 ambiguous across registers. Rewrites SPEC.md as requirements prose with Current:/Gap: on every one. It had drifted into a format-plus-API reference that documented schema_version 1 while owning a format whose v2 shape was specified only in the other two repos, said nothing about SR-002's scene-scoped semantics, and carried the manifest exchange as a "planned" aside while its configuration classes were already implemented. Plugin-side exchange obligations move here from the server's spec, where they were an ownership inversion. Adds JR-038..041 for PR-005, which had no software row in any repo -- it was held structurally by SR-004 and GR-005 both being prohibitions, and a goal preserved only by prohibitions is the kind that erodes unnoticed. jRay is the component that actually opens a socket. Tags 18 units with the requirements they satisfy. Tags name what the code satisfies, so FileTransformationRegistration is not tagged JR-021: that requirement is a prohibition and was still violated elsewhere when this was written. Vendors jray-project as a submodule for the system spec and shared gate. TRACES: JR-001, JR-004, JR-005, JR-007, JR-008, JR-009, JR-010, JR-011 TRACES: JR-012, JR-013, JR-014, JR-015, JR-016, JR-017, JR-018, JR-019 TRACES: JR-020, JR-024, JR-025, JR-036, JR-038 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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;
|
using MediaBrowser.Model.Plugins;
|
||||||
|
|
||||||
namespace Jellyfin.Plugin.JRay.Configuration;
|
namespace Jellyfin.Plugin.JRay.Configuration;
|
||||||
@@ -5,8 +6,28 @@ namespace Jellyfin.Plugin.JRay.Configuration;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Plugin configuration.
|
/// Plugin configuration.
|
||||||
/// </summary>
|
/// </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
|
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>
|
/// <summary>
|
||||||
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
@@ -15,6 +36,24 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
TruthFileSuffix = ".jray.json";
|
TruthFileSuffix = ".jray.json";
|
||||||
CacheDurationMinutes = 60;
|
CacheDurationMinutes = 60;
|
||||||
EnableOverlay = true;
|
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>
|
/// <summary>
|
||||||
@@ -37,4 +76,62 @@ public class PluginConfiguration : BasePluginConfiguration
|
|||||||
/// any previously injected script is removed.
|
/// any previously injected script is removed.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public bool EnableOverlay { get; set; }
|
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();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,12 +11,19 @@ using Microsoft.AspNetCore.Mvc;
|
|||||||
namespace Jellyfin.Plugin.JRay.Controllers;
|
namespace Jellyfin.Plugin.JRay.Controllers;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Exposes scene-actor-extraction "truth" data: which actors are on screen
|
/// Exposes scene-actor-extraction "truth" data: which actors are present in
|
||||||
/// at a given timestamp in a movie.
|
/// the scene at a given timestamp.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Items/{itemId}")]
|
[Route("Plugins/JRay/Items/{itemId}")]
|
||||||
[Authorize]
|
[Authorize]
|
||||||
|
// TRACES: JR-004, JR-005, JR-012, JR-013, JR-014 | SR-002
|
||||||
public class ActorsController : ControllerBase
|
public class ActorsController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ITruthDataService _truthDataService;
|
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
|
/// by genre), and supplies the genre/series option lists the config page's
|
||||||
/// rule editor needs.
|
/// rule editor needs.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Coverage")]
|
[Route("Plugins/JRay/Coverage")]
|
||||||
[Authorize(Roles = "Administrator")]
|
[Authorize(Roles = "Administrator")]
|
||||||
|
// TRACES: JR-018, JR-019 | PR-003
|
||||||
public class CoverageController : ControllerBase
|
public class CoverageController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly ILibraryManager _libraryManager;
|
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
|
/// 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.
|
/// replaces it, so a target can never be both prioritised and ignored.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Policy")]
|
[Route("Plugins/JRay/Policy")]
|
||||||
[Authorize(Roles = "Administrator")]
|
[Authorize(Roles = "Administrator")]
|
||||||
|
// TRACES: JR-016, JR-014 | PR-003
|
||||||
public class PolicyController : ControllerBase
|
public class PolicyController : ControllerBase
|
||||||
{
|
{
|
||||||
private readonly IMediaPolicyStore _policyStore;
|
private readonly IMediaPolicyStore _policyStore;
|
||||||
|
|||||||
@@ -19,9 +19,19 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
|||||||
/// Lets a remote extraction worker discover which library items still need
|
/// Lets a remote extraction worker discover which library items still need
|
||||||
/// to be processed.
|
/// to be processed.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Tasks")]
|
[Route("Plugins/JRay/Tasks")]
|
||||||
[Authorize(Roles = "Administrator")]
|
[Authorize(Roles = "Administrator")]
|
||||||
|
// TRACES: JR-015, JR-017 | PR-003
|
||||||
public class TasksController : ControllerBase
|
public class TasksController : ControllerBase
|
||||||
{
|
{
|
||||||
private const int DefaultLimit = 10;
|
private const int DefaultLimit = 10;
|
||||||
|
|||||||
@@ -12,11 +12,18 @@ namespace Jellyfin.Plugin.JRay.Controllers;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
|
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
|
||||||
/// extraction worker, for servers that cannot run the extraction pipeline
|
/// extraction worker, for servers that cannot run the extraction pipeline
|
||||||
/// locally. See SPEC.md.
|
/// locally. See SPEC.md §2.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay/Items/{itemId}/Truth")]
|
[Route("Plugins/JRay/Items/{itemId}/Truth")]
|
||||||
[Authorize(Roles = "Administrator")]
|
[Authorize(Roles = "Administrator")]
|
||||||
|
// TRACES: JR-003, JR-009, JR-014 | SR-003
|
||||||
public class TruthController : ControllerBase
|
public class TruthController : ControllerBase
|
||||||
{
|
{
|
||||||
private const int SupportedSchemaVersion = 1;
|
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
|
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
|
||||||
/// injected into the web client.
|
/// injected into the web client.
|
||||||
/// </summary>
|
/// </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]
|
[ApiController]
|
||||||
[Route("Plugins/JRay")]
|
[Route("Plugins/JRay")]
|
||||||
[AllowAnonymous]
|
[AllowAnonymous]
|
||||||
|
// TRACES: JR-014, JR-020 | PR-001
|
||||||
public class WebController : ControllerBase
|
public class WebController : ControllerBase
|
||||||
{
|
{
|
||||||
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
|
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
|
||||||
|
|||||||
@@ -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>
|
/// <summary>
|
||||||
/// One actor entry in a <see cref="TruthFile"/>, with the time windows during
|
/// 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>
|
/// </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
|
public class TruthActor
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -5,8 +5,19 @@ namespace Jellyfin.Plugin.JRay.Models;
|
|||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Root object of a scene-actor-extraction "truth" file
|
/// 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>
|
/// </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
|
public class TruthFile
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -14,6 +14,11 @@ namespace Jellyfin.Plugin.JRay.Services;
|
|||||||
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
|
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
|
||||||
/// worker) under the plugin's configuration directory, keyed by item id.
|
/// worker) under the plugin's configuration directory, keyed by item id.
|
||||||
/// </summary>
|
/// </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
|
public sealed class ManagedTruthStore : IManagedTruthStore
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
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
|
/// reads. All access is synchronised so the work-discovery API and the
|
||||||
/// config page can touch it concurrently.
|
/// config page can touch it concurrently.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// TRACES: JR-016 | PR-003
|
||||||
public sealed class MediaPolicyStore : IMediaPolicyStore
|
public sealed class MediaPolicyStore : IMediaPolicyStore
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
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
|
/// hold only one action, the only conflicts possible are across scopes, and
|
||||||
/// specificity resolves those.
|
/// specificity resolves those.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
|
// TRACES: JR-016 | PR-003
|
||||||
public static class PolicyResolver
|
public static class PolicyResolver
|
||||||
{
|
{
|
||||||
/// <summary>
|
/// <summary>
|
||||||
|
|||||||
@@ -16,6 +16,13 @@ namespace Jellyfin.Plugin.JRay.Services;
|
|||||||
/// item's source file, and caches the parsed result for a configurable
|
/// item's source file, and caches the parsed result for a configurable
|
||||||
/// duration.
|
/// duration.
|
||||||
/// </summary>
|
/// </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
|
public sealed class TruthDataService : ITruthDataService
|
||||||
{
|
{
|
||||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
|
||||||
|
|||||||
@@ -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 () {
|
(function () {
|
||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
|||||||
@@ -1,294 +1,786 @@
|
|||||||
# JRay truth file format
|
# jRay — software specification
|
||||||
|
|
||||||
JRay reads "truth" files produced offline by the
|
Status: **alpha.** Core read path ships; the schema bump, the exchange client and
|
||||||
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
|
the audio signature do not.
|
||||||
pipeline (`result_sink_node`, `Verbosity::minimal`, `schema_version: 1`).
|
|
||||||
|
|
||||||
## 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
|
jRay is the Jellyfin plugin: it **consumes** presence data, **displays** it in
|
||||||
`Movie.jray.json` (suffix configurable in the plugin settings, default
|
the player, and **owns the truth-file format** that the other two components
|
||||||
`.jray.json`). The plugin resolves this path from the Jellyfin item's media
|
produce and exchange.
|
||||||
source path by stripping the extension and appending the suffix.
|
|
||||||
|
|
||||||
## 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
|
```json
|
||||||
{
|
{
|
||||||
"schema_version": 1,
|
"schema_version": 2,
|
||||||
"movie": "/path/to/Movie.mkv",
|
"movie": "/data/movies/Movie.mkv",
|
||||||
"sample_fps": 1,
|
"extraction": {
|
||||||
"anneal_sec": 2,
|
"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": [
|
"actors": [
|
||||||
{
|
{
|
||||||
"name": "Tom Hanks",
|
"name": "Steve Buscemi",
|
||||||
"imdb_id": "nm0000158",
|
"imdb_id": "nm0000114",
|
||||||
"tmdb_id": "31",
|
"tmdb_id": "884",
|
||||||
"jellyfin_id": "abc123-guid",
|
"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
|
Field notes:
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
**Belief is an attribute, not part of identity.** Two servers that validated the
|
||||||
`scenes` windows satisfies `start <= t <= end`.
|
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
|
### JR-003 — Unknown `schema_version` is refused, never guessed
|
||||||
`X-Emby-Token` or `Authorization: MediaBrowser Token="..."`); the admin endpoints
|
|
||||||
additionally require the **Administrator** role. Only `GET /Plugins/JRay/ClientScript`
|
|
||||||
is anonymous.
|
|
||||||
|
|
||||||
### `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
|
All three components are pre-release and move together, and the alternative
|
||||||
data exists (neither a managed upload nor a sidecar file). Requires an
|
carries a cost that outlasts the transition: a v1 read path is the one nobody
|
||||||
authenticated user token.
|
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
|
**Current:** `PUT` rejects `schema_version != 1` with `400`; sidecar reads do not
|
||||||
data exists for the item. Requires an authenticated user token:
|
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
|
```json
|
||||||
{
|
{
|
||||||
"actors": [
|
"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
|
Future fields (`locations`, `trivia`, and per JR-005 a presence caveat) are added
|
||||||
without changing the route, so clients should ignore unknown keys.
|
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
|
| Route | Requires |
|
||||||
may push truth data directly. Requires an administrator API key. Body is a
|
|---|---|
|
||||||
truth file (schema above). Returns `204` on success, or `400` if
|
| `Timeline`, `jray?t=` | Authenticated Jellyfin user token |
|
||||||
`schema_version` is not `1`.
|
| `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
|
**Current:** all three implemented as stated. **Gap:** none.
|
||||||
`Movie.jray.json` file for the same item, and is stored independently of the
|
|
||||||
media library filesystem.
|
|
||||||
|
|
||||||
### `DELETE /Plugins/JRay/Items/{itemId}/Truth`
|
---
|
||||||
|
|
||||||
Removes managed truth data for an item (idempotent, always returns `204`).
|
## 4. Work discovery, policy and coverage — JR-015 … JR-019
|
||||||
The item falls back to its sidecar truth file, if any, on subsequent reads.
|
|
||||||
Requires an administrator API key.
|
|
||||||
|
|
||||||
### `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
|
A **random** sample (default 10, max 100) of movies and episodes with no truth
|
||||||
`index.html` (see below). Anonymous access.
|
data:
|
||||||
|
|
||||||
### `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):
|
|
||||||
|
|
||||||
```json
|
```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
|
Randomness is the design: repeated polling spreads work across the backlog
|
||||||
naturally spreads work across the backlog without needing server-side task
|
without the server tracking who is working on what, and two workers polling
|
||||||
tracking; an empty array means there's nothing left to do (or every remaining
|
concurrently mostly do not collide. An empty array means nothing is left, or that
|
||||||
item is a virtual/missing-path item that JRay can't process).
|
everything remaining is a virtual/missing-path item.
|
||||||
|
|
||||||
**Prioritise/ignore rules apply here.** Items covered by an **ignore** rule are
|
### JR-016, JR-017 — Prioritise / ignore rules
|
||||||
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.
|
|
||||||
|
|
||||||
## 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
|
Resolution picks the **most specific** match: `Item` > `Series` > `Genre`. A rule
|
||||||
rule targets a **genre**, a **series**, or a single **item**, and either
|
is keyed by scope + value, and setting one replaces any existing rule for the
|
||||||
**prioritises** (moves matching items to the front of `Tasks/Pending`) or
|
same key — so a single target can never be simultaneously prioritised and
|
||||||
**ignores** them (hides them from `Tasks/Pending` entirely). This is how you
|
ignored. Cross-scope conflicts (a prioritised series inside an ignored genre) are
|
||||||
say "never extract anime", "process this series first", or "skip this one
|
resolved by specificity: the series wins.
|
||||||
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:
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "scope": "Genre", "value": "Anime", "action": "Ignore", "label": "Anime" }
|
{ "scope": "Genre", "value": "Anime", "action": "Ignore", "label": "Anime" }
|
||||||
```
|
```
|
||||||
|
|
||||||
- `scope`: `"Genre"`, `"Series"`, or `"Item"`.
|
`value` is a genre name, a series id GUID, or an item id GUID; genre matching is
|
||||||
- `value`: a genre name (for `Genre`), a series id GUID (for `Series`), or an
|
case-insensitive. Persisted to `policy.json` in the plugin's configuration
|
||||||
item id GUID (for `Item`). Genre matching is case-insensitive.
|
directory.
|
||||||
- `action`: `"Prioritise"` or `"Ignore"`.
|
|
||||||
- `label`: optional human-readable label shown in the config UI (informational).
|
|
||||||
|
|
||||||
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`
|
How much of the library has truth data, overall and by media type and genre:
|
||||||
|
|
||||||
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.
|
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"total": { "total": 1200, "covered": 300, "pending": 850, "prioritised": 40, "ignored": 50 },
|
"total": { "total": 1200, "covered": 300, "pending": 850, "prioritised": 40, "ignored": 50 },
|
||||||
"by_media_type": [
|
"by_media_type": [ { "label": "Film", "counts": { "…": 0 } } ],
|
||||||
{ "label": "Film", "counts": { "total": 400, "covered": 200, "pending": 190, "prioritised": 10, "ignored": 10 } },
|
"by_genre": [ { "label": "Anime", "counts": { "…": 0 } } ]
|
||||||
{ "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 } }
|
|
||||||
]
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Each `counts` object buckets items as: `covered` (has truth data),
|
`prioritised` is a subset of `pending`. Percent done is
|
||||||
`pending` (needs processing and not ignored; `prioritised` is the subset of
|
`covered / (total - ignored)` — **ignoring a genre does not drag the percentage
|
||||||
`pending` under a prioritise rule), and `ignored` (excluded by an ignore rule).
|
down**, because ignored items are intentionally out of scope, not outstanding
|
||||||
`total` is the sum. A useful "percent done" is `covered / (total - ignored)`,
|
work. An item counts toward every genre it carries, so genre rows overlap and
|
||||||
so ignoring a genre or series does **not** drag the percentage down — ignored
|
need not sum to the library total.
|
||||||
items are treated as intentionally out of scope.
|
|
||||||
|
|
||||||
An item counts toward every genre it carries, so genre rows can overlap and
|
### JR-019 — Pickers
|
||||||
their totals need not sum to the library total.
|
|
||||||
|
|
||||||
### 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
|
**Current (JR-015 … JR-019):** all implemented. **Gap:** none functionally —
|
||||||
**Administrator** API key, all return `[{ "value": ..., "label": ... }]`):
|
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
|
### JR-020 — The overlay
|
||||||
Jellyfin (i.e. it cannot write a `Movie.jray.json` sidecar next to the media
|
|
||||||
file) can push results directly over HTTP.
|
|
||||||
|
|
||||||
To find work, poll `GET /Plugins/JRay/Tasks/Pending?limit=10` (see above) for
|
Jellyfin has no plugin hook for player UI, so jRay adds
|
||||||
a random batch of items that still need processing, instead of walking the
|
`<script defer src="/Plugins/JRay/ClientScript"></script>` to the web client's
|
||||||
whole library and checking each item's `Timeline`/sidecar yourself.
|
`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
|
### JR-021 — jRay never injects into `index.html` on disk
|
||||||
send it on every request as either:
|
|
||||||
|
|
||||||
```
|
**File Transformation is a hard requirement, not a preference. There is no
|
||||||
X-Emby-Token: <api-key>
|
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.
|
||||||
|
|
||||||
```
|
Writing to `index.html` is rejected because it is destructive in ways a plugin
|
||||||
Authorization: MediaBrowser Token="<api-key>"
|
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
|
**Current:** satisfied.
|
||||||
find it for `Movie.mkv`:
|
[`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
|
GET /Items?Recursive=true&Fields=Path&IncludeItemTypes=Movie,Episode
|
||||||
```
|
```
|
||||||
|
|
||||||
(use `&ParentId=<library-id>` to narrow the search if the library is large).
|
Match `Path` against the file you processed — which requires the worker to see
|
||||||
Each returned item DTO has `Id` (the GUID) and `Path`. Match `Path` against
|
the file at the *same path* Jellyfin does; translate first if it mounts the
|
||||||
the absolute path of the file you just processed — note this requires the
|
library elsewhere. The mapping is stable until the file moves, so cache
|
||||||
worker to see the file at the *same path* Jellyfin does (same mount/share);
|
`path -> itemId` and re-resolve only on a miss.
|
||||||
translate paths first if the worker mounts the library elsewhere.
|
|
||||||
|
|
||||||
This mapping is stable until the file is moved/re-scanned, so the worker
|
**4. Push.** `PUT /Plugins/JRay/Items/{itemId}/Truth` with the truth file body.
|
||||||
should cache `path -> itemId` and only re-resolve on a cache miss.
|
`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
|
1. **`sample_fps`, `gallery_size` and `pipeline_version` move under
|
||||||
this item is invalidated server-side).
|
`extraction.*` in JR-002.** This aligns the truth file with the Jmanifest's
|
||||||
- `400 Bad Request` — `schema_version` is not `1`.
|
block of the same name, and the bump is breaking regardless. It is a change
|
||||||
- `401`/`403` — API key missing or not an administrator.
|
this spec proposes rather than one inherited from SR-003's list — confirm, or
|
||||||
|
keep them top-level.
|
||||||
The `PUT` is idempotent (replaces any existing managed truth for the item),
|
2. **Does `route` belong in the `jray?t=` envelope?** JR-013 says the response is
|
||||||
so the worker can safely retry on network errors.
|
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
|
||||||
### 4. (Optional) Remove pushed data
|
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
|
||||||
DELETE /Plugins/JRay/Items/{itemId}/Truth
|
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
|
||||||
Always returns `204`. The item falls back to a sidecar `Movie.jray.json` (if
|
`UT-001` will exist in both this repo and `scene-actor-extraction`. Fine while
|
||||||
any) on the next read.
|
the gate runs per repo; ambiguous the moment a rollup spans them.
|
||||||
|
|
||||||
## 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.
|
|
||||||
|
|||||||
@@ -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.
|
||||||
+1
Submodule scripts/vendor/jray-project added at 041961c8c6
Reference in New Issue
Block a user