diff --git a/.gitmodules b/.gitmodules
new file mode 100644
index 0000000..42e519c
--- /dev/null
+++ b/.gitmodules
@@ -0,0 +1,3 @@
+[submodule "scripts/vendor/jray-project"]
+ path = scripts/vendor/jray-project
+ url = git@gitea.tourolle.paris:dtourolle/jray-project.git
diff --git a/Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs b/Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs
new file mode 100644
index 0000000..a7f4f5b
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs
@@ -0,0 +1,110 @@
+namespace Jellyfin.Plugin.JRay.Configuration;
+
+///
+/// How far a configured manifest server is trusted. See the JRay public server
+/// specification, §9 "Trusting third-party servers".
+///
+public enum ServerTrustLevel
+{
+ ///
+ /// 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.
+ ///
+ FetchOnly = 0,
+
+ ///
+ /// Eligible to contribute to, subject to .
+ ///
+ Full = 1,
+}
+
+///
+/// The minimum cut-match tier a fetched manifest must reach before it is stored.
+/// See the public server specification, §3 "Cut matching".
+///
+public enum MatchTier
+{
+ /// Audio 0.60–0.85, or runtimes within ±30s. Surfaced as a caveat in the UI.
+ Loose = 0,
+
+ /// Runtimes within ±2s.
+ Runtime = 1,
+
+ /// Audio signature score ≥ 0.85; may carry a non-zero offset.
+ Audio = 2,
+
+ /// Identical video_hash — the same file.
+ Exact = 3,
+}
+
+///
+/// One entry in the ordered list of manifest servers the plugin queries
+/// (public server specification, §9 "Multiple servers").
+///
+///
+/// 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.
+///
+/// FetchOnly 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.
+///
+// TRACES: JR-025 | PR-005, PR-006
+public class ManifestServer
+{
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ public ManifestServer()
+ {
+ Url = string.Empty;
+ Name = string.Empty;
+ Token = string.Empty;
+ Enabled = false;
+ AllowContribute = false;
+ TrustLevel = ServerTrustLevel.FetchOnly;
+ }
+
+ ///
+ /// 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.
+ ///
+ public string Url { get; set; }
+
+ ///
+ /// Gets or sets the display label shown in the configuration page.
+ ///
+ public string Name { get; set; }
+
+ ///
+ /// 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).
+ ///
+ public string Token { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool Enabled { get; set; }
+
+ ///
+ /// 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.
+ ///
+ public bool AllowContribute { get; set; }
+
+ ///
+ /// Gets or sets how far this server is trusted.
+ ///
+ public ServerTrustLevel TrustLevel { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs b/Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs
index 1b424dc..6b80d79 100644
--- a/Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs
+++ b/Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs
@@ -1,3 +1,4 @@
+using System.Collections.ObjectModel;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.JRay.Configuration;
@@ -5,8 +6,28 @@ namespace Jellyfin.Plugin.JRay.Configuration;
///
/// Plugin configuration.
///
+///
+/// Every manifest-exchange switch here defaults to off, 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.
+///
+// TRACES: JR-036, JR-038 | PR-005
public class PluginConfiguration : BasePluginConfiguration
{
+ ///
+ /// The community manifest exchange. Shipped pre-configured but
+ /// disabled, so no traffic leaves an installation until an admin opts
+ /// in (public server specification, §9).
+ ///
+ public const string CommunityServerUrl = "https://jray.tourolle.paris";
+
+ ///
+ /// Display name for .
+ ///
+ public const string CommunityServerName = "JRay Community";
+
///
/// Initializes a new instance of the class.
///
@@ -15,6 +36,24 @@ public class PluginConfiguration : BasePluginConfiguration
TruthFileSuffix = ".jray.json";
CacheDurationMinutes = 60;
EnableOverlay = true;
+
+ // Manifest sharing is a network egress feature, so every part of it is
+ // off by default (public server specification, §9 "Configuration").
+ EnableManifestSharing = false;
+ ContributeManifests = false;
+ ComputeAudioSignatures = false;
+ MinimumMatchTier = MatchTier.Runtime;
+
+ // Pre-configured but disabled: the admin opts in by enabling it, rather
+ // than by having to discover and type a URL.
+ Servers.Add(new ManifestServer
+ {
+ Url = CommunityServerUrl,
+ Name = CommunityServerName,
+ Enabled = false,
+ AllowContribute = false,
+ TrustLevel = ServerTrustLevel.FetchOnly,
+ });
}
///
@@ -37,4 +76,62 @@ public class PluginConfiguration : BasePluginConfiguration
/// any previously injected script is removed.
///
public bool EnableOverlay { get; set; }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// 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.
+ ///
+ public bool EnableManifestSharing { get; set; }
+
+ ///
+ /// Gets or sets a value indicating whether locally generated manifests may be
+ /// contributed back. A separate opt-in from downloading, and off by default.
+ ///
+ ///
+ /// Contribution additionally requires
+ /// on the specific server and a token for it. Uploads are never fanned out to
+ /// every configured server.
+ ///
+ public bool ContributeManifests { get; set; }
+
+ ///
+ /// Gets or sets the minimum cut-match tier a fetched manifest must reach
+ /// before it is stored.
+ ///
+ ///
+ /// Defaults to .
+ /// 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.
+ ///
+ public MatchTier MinimumMatchTier { get; set; }
+
+ ///
+ /// 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.
+ ///
+ ///
+ /// Off by default. Uses the FFmpeg binary Jellyfin already ships (via
+ /// IMediaEncoder.EncoderPath), so there is no extra dependency, but it
+ /// costs roughly a second or two of I/O per item and is therefore opt-in.
+ ///
+ public bool ComputeAudioSignatures { get; set; }
+
+ ///
+ /// Gets the ordered list of manifest servers.
+ ///
+ ///
+ /// Order is the user's trust ranking: for a fetch, servers are tried in order
+ /// and the first result clearing wins. For a
+ /// series, first-match applies per episode, so a later server is
+ /// queried only for the episodes earlier ones lacked.
+ ///
+ public Collection Servers { get; } = new();
}
diff --git a/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs b/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs
index a9befd6..9bd6936 100644
--- a/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs
+++ b/Jellyfin.Plugin.JRay/Controllers/ActorsController.cs
@@ -11,12 +11,19 @@ using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
///
-/// Exposes scene-actor-extraction "truth" data: which actors are on screen
-/// at a given timestamp in a movie.
+/// Exposes scene-actor-extraction "truth" data: which actors are present in
+/// the scene at a given timestamp.
///
+///
+/// Presence is scene-scoped, 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.
+///
[ApiController]
[Route("Plugins/JRay/Items/{itemId}")]
[Authorize]
+// TRACES: JR-004, JR-005, JR-012, JR-013, JR-014 | SR-002
public class ActorsController : ControllerBase
{
private readonly ITruthDataService _truthDataService;
diff --git a/Jellyfin.Plugin.JRay/Controllers/CoverageController.cs b/Jellyfin.Plugin.JRay/Controllers/CoverageController.cs
index 7e65db3..7478654 100644
--- a/Jellyfin.Plugin.JRay/Controllers/CoverageController.cs
+++ b/Jellyfin.Plugin.JRay/Controllers/CoverageController.cs
@@ -19,9 +19,15 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// by genre), and supplies the genre/series option lists the config page's
/// rule editor needs.
///
+///
+/// Percent done is covered / (total - ignored): ignored items are
+/// intentionally out of scope, so excluding a genre must not drag the figure
+/// down as though it were outstanding work.
+///
[ApiController]
[Route("Plugins/JRay/Coverage")]
[Authorize(Roles = "Administrator")]
+// TRACES: JR-018, JR-019 | PR-003
public class CoverageController : ControllerBase
{
private readonly ILibraryManager _libraryManager;
diff --git a/Jellyfin.Plugin.JRay/Controllers/PolicyController.cs b/Jellyfin.Plugin.JRay/Controllers/PolicyController.cs
index 4b506ff..e139229 100644
--- a/Jellyfin.Plugin.JRay/Controllers/PolicyController.cs
+++ b/Jellyfin.Plugin.JRay/Controllers/PolicyController.cs
@@ -13,9 +13,15 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// series, or a single item; setting a rule for a target that already has one
/// replaces it, so a target can never be both prioritised and ignored.
///
+///
+/// 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".
+///
[ApiController]
[Route("Plugins/JRay/Policy")]
[Authorize(Roles = "Administrator")]
+// TRACES: JR-016, JR-014 | PR-003
public class PolicyController : ControllerBase
{
private readonly IMediaPolicyStore _policyStore;
diff --git a/Jellyfin.Plugin.JRay/Controllers/TasksController.cs b/Jellyfin.Plugin.JRay/Controllers/TasksController.cs
index 1f2e6cd..bec2170 100644
--- a/Jellyfin.Plugin.JRay/Controllers/TasksController.cs
+++ b/Jellyfin.Plugin.JRay/Controllers/TasksController.cs
@@ -19,9 +19,19 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// Lets a remote extraction worker discover which library items still need
/// to be processed.
///
+///
+/// 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 here and only here (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.
+///
[ApiController]
[Route("Plugins/JRay/Tasks")]
[Authorize(Roles = "Administrator")]
+// TRACES: JR-015, JR-017 | PR-003
public class TasksController : ControllerBase
{
private const int DefaultLimit = 10;
diff --git a/Jellyfin.Plugin.JRay/Controllers/TruthController.cs b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs
index 0e589ed..08c1cd5 100644
--- a/Jellyfin.Plugin.JRay/Controllers/TruthController.cs
+++ b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs
@@ -12,11 +12,18 @@ namespace Jellyfin.Plugin.JRay.Controllers;
///
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
/// extraction worker, for servers that cannot run the extraction pipeline
-/// locally. See SPEC.md.
+/// locally. See SPEC.md §2.
///
+///
+/// The schema_version 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.
+///
[ApiController]
[Route("Plugins/JRay/Items/{itemId}/Truth")]
[Authorize(Roles = "Administrator")]
+// TRACES: JR-003, JR-009, JR-014 | SR-003
public class TruthController : ControllerBase
{
private const int SupportedSchemaVersion = 1;
diff --git a/Jellyfin.Plugin.JRay/Controllers/WebController.cs b/Jellyfin.Plugin.JRay/Controllers/WebController.cs
index 6fe874f..74509a6 100644
--- a/Jellyfin.Plugin.JRay/Controllers/WebController.cs
+++ b/Jellyfin.Plugin.JRay/Controllers/WebController.cs
@@ -11,9 +11,14 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
/// injected into the web client.
///
+///
+/// Anonymous by necessity, not by oversight: the script tag is injected into
+/// index.html, which is served before a user has logged in.
+///
[ApiController]
[Route("Plugins/JRay")]
[AllowAnonymous]
+// TRACES: JR-014, JR-020 | PR-001
public class WebController : ControllerBase
{
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
diff --git a/Jellyfin.Plugin.JRay/Models/TransformationPayload.cs b/Jellyfin.Plugin.JRay/Models/TransformationPayload.cs
new file mode 100644
index 0000000..fef368c
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/TransformationPayload.cs
@@ -0,0 +1,22 @@
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// The object handed to JRay's transformation callback by the File
+/// Transformation plugin.
+///
+///
+/// That plugin builds a Newtonsoft JObject with a single contents
+/// key and calls JObject.ToObject(parameterType) against this type.
+/// Newtonsoft binds member names case-insensitively, so
+/// binds to contents without an attribute — and a System.Text.Json
+/// attribute would have no effect here.
+///
+public class TransformationPayload
+{
+ ///
+ /// Gets or sets the current contents of the file being served, including
+ /// any transformations applied by other plugins ahead of JRay in the
+ /// chain.
+ ///
+ public string? Contents { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/TruthActor.cs b/Jellyfin.Plugin.JRay/Models/TruthActor.cs
index 91cb1bc..7ba9620 100644
--- a/Jellyfin.Plugin.JRay/Models/TruthActor.cs
+++ b/Jellyfin.Plugin.JRay/Models/TruthActor.cs
@@ -5,8 +5,19 @@ namespace Jellyfin.Plugin.JRay.Models;
///
/// One actor entry in a , with the time windows during
-/// which they are visible on screen.
+/// which they are present in the scene.
///
+///
+/// "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. jellyfin_id is preferred locally and stripped on
+/// contribution, being meaningless outside the instance that produced it.
+///
+// TRACES: JR-004, JR-007 | SR-001, SR-002
public class TruthActor
{
///
diff --git a/Jellyfin.Plugin.JRay/Models/TruthFile.cs b/Jellyfin.Plugin.JRay/Models/TruthFile.cs
index f893010..b020866 100644
--- a/Jellyfin.Plugin.JRay/Models/TruthFile.cs
+++ b/Jellyfin.Plugin.JRay/Models/TruthFile.cs
@@ -5,8 +5,19 @@ namespace Jellyfin.Plugin.JRay.Models;
///
/// 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.
///
+///
+/// JRay owns 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
+/// schema_version bump rather than made piecemeal.
+///
+/// This type is still the v1 shape. JR-002 replaces it: anneal_sec out,
+/// an extraction provenance block and a cut block in, and
+/// scenes becoming objects that carry belief and identification route.
+///
+// TRACES: JR-001, JR-002 | SR-003
public class TruthFile
{
///
diff --git a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs
index cc099c5..8af4730 100644
--- a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs
+++ b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs
@@ -14,6 +14,11 @@ namespace Jellyfin.Plugin.JRay.Services;
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
/// worker) under the plugin's configuration directory, keyed by item id.
///
+///
+/// Deliberately outside the media library filesystem, so a worker that cannot
+/// write beside the media file is not a second-class producer.
+///
+// TRACES: JR-009, JR-010 | PR-004
public sealed class ManagedTruthStore : IManagedTruthStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
diff --git a/Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs b/Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs
index b69f692..3095110 100644
--- a/Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs
+++ b/Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs
@@ -17,6 +17,7 @@ namespace Jellyfin.Plugin.JRay.Services;
/// reads. All access is synchronised so the work-discovery API and the
/// config page can touch it concurrently.
///
+// TRACES: JR-016 | PR-003
public sealed class MediaPolicyStore : IMediaPolicyStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
diff --git a/Jellyfin.Plugin.JRay/Services/PolicyResolver.cs b/Jellyfin.Plugin.JRay/Services/PolicyResolver.cs
index 06a73fa..55207d5 100644
--- a/Jellyfin.Plugin.JRay/Services/PolicyResolver.cs
+++ b/Jellyfin.Plugin.JRay/Services/PolicyResolver.cs
@@ -11,6 +11,7 @@ namespace Jellyfin.Plugin.JRay.Services;
/// hold only one action, the only conflicts possible are across scopes, and
/// specificity resolves those.
///
+// TRACES: JR-016 | PR-003
public static class PolicyResolver
{
///
diff --git a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs
index 5cc0b48..1d02851 100644
--- a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs
+++ b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs
@@ -16,6 +16,13 @@ namespace Jellyfin.Plugin.JRay.Services;
/// item's source file, and caches the parsed result for a configurable
/// duration.
///
+///
+/// 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.
+///
+// TRACES: JR-008, JR-010, JR-011 | PR-001
public sealed class TruthDataService : ITruthDataService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
diff --git a/Jellyfin.Plugin.JRay/Web/jray-overlay.js b/Jellyfin.Plugin.JRay/Web/jray-overlay.js
index 79726e9..bce1d11 100644
--- a/Jellyfin.Plugin.JRay/Web/jray-overlay.js
+++ b/Jellyfin.Plugin.JRay/Web/jray-overlay.js
@@ -1,3 +1,18 @@
+/*
+ * JRay pause overlay: lists the cast of the scene the viewer paused in.
+ *
+ * Presence is scene-scoped. An actor who has turned away, is occluded, or is
+ * off-camera during a reverse shot is still in the scene, so this must not be
+ * presented as "who is visible right now" — that is a different, and weaker,
+ * claim than the data makes.
+ *
+ * Every server-supplied string is written with textContent, never innerHTML.
+ * With the manifest exchange these strings may originate from a third-party
+ * server, and this is the one control that holds even if every other check is
+ * bypassed.
+ *
+ * TRACES: JR-005, JR-020, JR-024 | SR-002, SR-004
+ */
(function () {
'use strict';
diff --git a/SPEC.md b/SPEC.md
index b27b862..7a77005 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -1,294 +1,786 @@
-# JRay truth file format
+# jRay — software specification
-JRay reads "truth" files produced offline by the
-[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
-pipeline (`result_sink_node`, `Verbosity::minimal`, `schema_version: 1`).
+Status: **alpha.** Core read path ships; the schema bump, the exchange client and
+the audio signature do not.
-## File location
+This is a *software* spec: its job is to implement the
+[system spec](scripts/vendor/jray-project/SPEC.md), which owns everything spanning more than one repo.
+Requirements here trace up to an `SR-nnn` or a `PR-nnn`; the prose below is the
+detail. The authoritative ID list with status lives in
+[`docs/requirements.md`](docs/requirements.md).
-For a media file `Movie.mkv`, the pipeline writes a sibling file
-`Movie.jray.json` (suffix configurable in the plugin settings, default
-`.jray.json`). The plugin resolves this path from the Jellyfin item's media
-source path by stripping the extension and appending the suffix.
+jRay is the Jellyfin plugin: it **consumes** presence data, **displays** it in
+the player, and **owns the truth-file format** that the other two components
+produce and exchange.
-## JSON schema (schema_version 1, minimal verbosity)
+---
+
+## 0. Requirements
+
+IDs are `JR-nnn`, zero-padded and **permanent** — a withdrawn requirement keeps
+its number, because renumbering is what produces orphan TRACES tags
+([system spec](scripts/vendor/jray-project/SPEC.md) §6).
+
+| Group | IDs | Where addressed |
+|---|---|---|
+| Truth-file format | JR-001 … JR-007 | §1 |
+| Sources and precedence | JR-008 … JR-011 | §2 |
+| Read API | JR-012 … JR-014 | §3 |
+| Work discovery, policy, coverage | JR-015 … JR-019 | §4 |
+| Player overlay | JR-020 … JR-024 | §5 |
+| Manifest exchange client | JR-025 … JR-037 | §6 |
+| Egress and privacy | JR-038 … JR-041 | §7 |
+| Audio signature | JR-042 … JR-045 | §8 |
+| Human-in-the-loop association | JR-046 | §9 |
+
+**JR-038 … JR-041 exist because `PR-005` had no software row anywhere.** The
+system spec notes that "leak nothing about what the user owns" is preserved
+structurally — by SR-004 and GR-005 both being prohibitions — and that a goal
+held only by prohibitions needs watching. jRay is the component that actually
+opens a socket, so it is the right place for that goal to become checkable.
+
+---
+
+## 1. Truth-file format — JR-001 … JR-007
+
+### JR-001 — This document is normative for the format
+
+The truth file is produced by `scene-actor-extraction`, read by this plugin, and
+transformed into a Jmanifest by the exchange client. Three repos touch it, so
+exactly one must define it, and the [system spec](scripts/vendor/jray-project/SPEC.md) §1 assigns that to
+jRay. Other repos reference this section rather than restating the schema.
+
+The truth file is **not** the Jmanifest. It carries installation-local fields
+(`movie`, `jellyfin_id`) that the exchange strips, and lacks the portable
+identity block the exchange adds. See §6.
+
+**Current:** [`Models/TruthFile.cs`](Jellyfin.Plugin.JRay/Models/TruthFile.cs),
+`schema_version: 1`. **Gap:** the schema below is not implemented, and the other
+two specs describe the pending bump in more detail than this one does — the
+ownership is stated but not yet exercised.
+
+### JR-002 — `schema_version: 2`
```json
{
- "schema_version": 1,
- "movie": "/path/to/Movie.mkv",
- "sample_fps": 1,
- "anneal_sec": 2,
+ "schema_version": 2,
+ "movie": "/data/movies/Movie.mkv",
+ "extraction": {
+ "sample_fps": 5,
+ "extinction_sec": 12,
+ "gallery_size": 1820,
+ "gallery_scope": "global",
+ "pipeline_version": "scene-actor-extraction 0.4.1"
+ },
+ "cut": {
+ "runtime_sec": 6420.5,
+ "audio_signature": "v1:v7fA3k…"
+ },
"actors": [
{
- "name": "Tom Hanks",
- "imdb_id": "nm0000158",
- "tmdb_id": "31",
+ "name": "Steve Buscemi",
+ "imdb_id": "nm0000114",
+ "tmdb_id": "884",
"jellyfin_id": "abc123-guid",
- "scenes": [[12.0, 45.0], [102.5, 150.0]]
+ "scenes": [
+ { "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" },
+ { "start": 438.2, "end": 465.6, "belief": 0.81, "route": "deferred" }
+ ]
}
]
}
```
-- `schema_version`: integer, bump on breaking changes. JRay should refuse (or
- warn) on a version it doesn't understand.
-- `movie`: absolute path to the source media file at extraction time (informational only).
-- `sample_fps`: frames-per-second the pipeline sampled at.
-- `anneal_sec`: gap (in seconds) below which consecutive detections of the
- same actor were merged into a single scene window.
-- `actors[]`: one entry per actor detected anywhere in the film.
- - `name`: display name from the gallery.
- - `imdb_id` / `tmdb_id` / `jellyfin_id`: identity keys, each `""` if not
- resolved. JRay should prefer `jellyfin_id` (a Jellyfin Person item GUID)
- when non-empty, and otherwise resolve `imdb_id`/`tmdb_id` against the
- item's People `ProviderIds`.
- - `scenes`: list of `[start_sec, end_sec]` windows (inclusive) during which
- the actor is on screen.
+Field notes:
-## Querying "who's on screen at time t"
+- `schema_version` — **system-level** (SR-003), incremented once per breaking
+ change and referenced by the same number in all three repos.
+- `movie` — absolute path at extraction time, informational only. Stripped on
+ contribution (JR-034).
+- `extraction.*` — provenance. `sample_fps`, `gallery_size` and
+ `pipeline_version` move here from the top level so the truth file and the
+ Jmanifest's `extraction` block have the same shape, rather than differing for
+ no reason.
+- `extraction.extinction_sec` — **replaces `anneal_sec`**, which is deleted, not
+ retained as a vestigial `0`. It is the re-acquisition timeout that shapes
+ window extent, so it is what a consumer needs in order to interpret a window.
+- `extraction.gallery_scope` — `"global"` or `"limited"`. The strongest single
+ quality signal when two manifests compete for one cut.
+- `cut.runtime_sec` — the decoded duration the timings came from. Required for
+ contribution; the primary alignment guard.
+- `cut.audio_signature` — optional, `v1:`-prefixed. See §8.
+- `actors[].scenes[]` — objects, not float pairs. `start`/`end` in seconds,
+ inclusive, sorted. `belief` is the accumulated posterior that justified the
+ claim; `route` is `"live"`, `"deferred"` or `"pooled"` (extraction AR-017).
-For a given timestamp `t` (seconds), an actor is visible if any of their
-`scenes` windows satisfies `start <= t <= end`.
+**Belief is an attribute, not part of identity.** Two servers that validated the
+same upload must agree on its `content_id`, and belief is a producer-side
+estimate that may legitimately differ between pipeline versions for identical
+timings. It replicates the way `audio_signature` does — see the server spec §9a.
-## API
+**Gap:** entire requirement. The changes are all breaking and ship as **one**
+bump (SR-003), together with extraction `IR-002` and the server's acceptance of
+the new shape.
-All read endpoints below require an authenticated Jellyfin user token (passed as
-`X-Emby-Token` or `Authorization: MediaBrowser Token="..."`); the admin endpoints
-additionally require the **Administrator** role. Only `GET /Plugins/JRay/ClientScript`
-is anonymous.
+### JR-003 — Unknown `schema_version` is refused, never guessed
-### `GET /Plugins/JRay/Items/{itemId}/Timeline`
+**Decision: flag day.** The plugin accepts `schema_version: 2` and rejects
+everything else, on every path — sidecar read, managed `PUT`, and fetched
+manifest. There is no transitional dual-accept.
-Returns the full truth file (schema above) for an item, or `404` if no truth
-data exists (neither a managed upload nor a sidecar file). Requires an
-authenticated user token.
+All three components are pre-release and move together, and the alternative
+carries a cost that outlasts the transition: a v1 read path is the one nobody
+exercises, so it is the one that rots, and it would have to be carried through
+every subsequent change to the reader.
-### `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
+**The consequence must be stated plainly rather than discovered:** existing v1
+sidecar files on disk **stop being read** at the bump, and stay dark until the
+library is re-extracted. The plugin logs this per item, naming the file and the
+version found, rather than silently reporting no coverage — an item that looks
+un-extracted when it was merely stale is the failure mode that wastes a user's
+compute.
-Returns an extensible "context at time t" envelope, or `404` if no truth
-data exists for the item. Requires an authenticated user token:
+**Current:** `PUT` rejects `schema_version != 1` with `400`; sidecar reads do not
+check the version at all. **Gap:** the version check must move into the shared
+read path so all three sources are covered, and the target becomes `2`.
+
+### JR-004 — A window is a scene-membership claim
+
+**This is SR-002, and it binds this plugin harder than it binds anything else,**
+because jRay is where the claim reaches a human.
+
+An actor who turns away, is occluded, or is off-camera while the shot cuts to
+whoever they are speaking to **is still present**. Two windows mean a genuine
+departure and return, not a break in detection. Gaps shorter than
+`extinction_sec` were absorbed upstream and are claimed as presence.
+
+The plugin therefore **never reinterprets, merges, splits, or trims windows.**
+It stores and serves what it was given. The one permitted transformation is the
+timebase offset of JR-030, which shifts every window uniformly and so preserves
+the claim.
+
+**Gap:** stated nowhere in the code today. The read path happens to comply, but
+by not having been written to do otherwise rather than by requirement.
+
+### JR-005 — Query semantics, and how presence is presented
+
+An actor is present at `t` if any window satisfies `start <= t <= end`.
+
+**The presentation must not assert instantaneous visibility.** SR-002 is explicit
+that a consumer must never interpret window boundaries as "the face was detected
+here", and the overlay is the exact place that misreading would be made
+user-visible. "On screen now" is a claim the data does not support; "in this
+scene" is the claim it does.
+
+This is a wording requirement, not a hedge — it is the difference between the
+product being right and being a worse version of a frame-by-frame detector.
+
+**Current:** the query is implemented correctly in
+[`ActorsController`](Jellyfin.Plugin.JRay/Controllers/ActorsController.cs).
+**Gap:** wording, not logic. The overlay renders a bare list with **no heading
+at all**, so it asserts nothing — but it also tells the viewer nothing about
+what the list means, and a viewer's default reading of a paused frame is "these
+people are on screen". [`README.md`](README.md) states that reading outright
+("which actors are on screen at that exact moment"), and the model type is
+`ActorAtTime`.
+
+### JR-006 — Numerous windows
+
+SR-002 warns that windows may be numerous and consumers must not assume a handful
+of long ones. Track-extent presence with a short `extinction_sec` produces many
+short windows per actor, and the previous design's few long ones were an artefact
+of the over-claiming that was removed.
+
+The read path must therefore treat per-actor windows as a sorted sequence to be
+searched, not a short list to be scanned, and the `jray?t=` response must stay
+small regardless of how many windows an actor has.
+
+**Gap:** windows are scanned linearly and the whole truth file is held per item.
+Adequate at current sizes; unmeasured, and unstated until now.
+
+### JR-007 — Identity is public identifiers
+
+Each actor carries `imdb_id`, `tmdb_id` and `jellyfin_id`, any of which may be
+`""`. Resolution prefers `jellyfin_id` (a Jellyfin Person GUID) when non-empty,
+and otherwise matches `imdb_id`/`tmdb_id` against the item's People
+`ProviderIds`. Never a name alone — names are ambiguous and unstable (SR-001).
+
+`jellyfin_id` is the exception that proves the rule: it is meaningful only on the
+instance that produced it, which is exactly why the exchange strips it (JR-034).
+
+**Current:** implemented. **Gap:** none.
+
+---
+
+## 2. Truth-data sources and precedence — JR-008 … JR-011
+
+### JR-008 — Sidecar discovery
+
+For `Movie.mkv`, the plugin looks for `Movie.jray.json` beside it — suffix
+configurable, default `.jray.json`, resolved from the item's media source path.
+
+**Current:** implemented. **Gap:** none.
+
+### JR-009 — Managed truth push
+
+`PUT`/`DELETE .../Truth` let a worker that cannot write beside the media file
+deliver results over HTTP. Stored under the plugin's configuration directory,
+keyed by item id, independent of the library filesystem.
+
+**Current:** implemented. **Gap:** none.
+
+### JR-010 — Precedence and provenance
+
+There are now **three** sources: a sidecar file, a push from a local worker, and
+a manifest fetched from a server. Managed truth — pushed *or* fetched — takes
+precedence over a sidecar.
+
+**Fetched manifests are stored through the managed store**, so precedence stays a
+two-way rule rather than a three-way one, and the read path does not learn about
+the exchange at all.
+
+But the three are no longer interchangeable, so **provenance is recorded with the
+stored truth**: which source it came from, and for a fetched one, which server
+and at what match tier. A `loose`-tier fetch from a third-party server and a
+locally-computed sidecar are not the same claim, and JR-036 requires the
+difference be surfaceable.
+
+**Current:** two-way precedence implemented in
+[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs).
+**Gap:** no provenance is recorded.
+
+### JR-011 — Caching
+
+Loaded truth is cached in memory for a configurable duration. Any write —
+managed `PUT`, `DELETE`, or a stored fetch — invalidates that item's entry
+immediately, so a push takes effect without waiting for expiry.
+
+**Current:** implemented. **Gap:** none.
+
+---
+
+## 3. Read API — JR-012 … JR-014
+
+### JR-012 — `GET /Plugins/JRay/Items/{itemId}/Timeline`
+
+Returns the full truth file (§1), or `404` if no truth data exists from any
+source.
+
+### JR-013 — `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
+
+Returns an extensible "context at time `t`" envelope, or `404`:
```json
{
"actors": [
- { "name": "Tom Hanks", "imdb_id": "nm0000158", "tmdb_id": "31", "jellyfin_id": "abc123-guid" }
+ { "name": "Steve Buscemi", "imdb_id": "nm0000114", "tmdb_id": "884", "jellyfin_id": "abc123-guid" }
]
}
```
-Future fields (e.g. `locations`, `trivia`) will be added to this object
-without changing the route, so clients should ignore unknown keys.
+Future fields (`locations`, `trivia`, and per JR-005 a presence caveat) are added
+to this object without changing the route, so **clients must ignore unknown
+keys**.
-### `PUT /Plugins/JRay/Items/{itemId}/Truth`
+### JR-014 — Authorisation
-For servers that cannot run the extraction pipeline locally, a remote worker
-may push truth data directly. Requires an administrator API key. Body is a
-truth file (schema above). Returns `204` on success, or `400` if
-`schema_version` is not `1`.
+| Route | Requires |
+|---|---|
+| `Timeline`, `jray?t=` | Authenticated Jellyfin user token |
+| `Truth`, `Tasks/*`, `Policy/*`, `Coverage/*`, and §6's fetch routes | **Administrator** role |
+| `ClientScript` | Anonymous — it is injected into a page served before login |
-This "managed" truth data takes precedence over any sidecar
-`Movie.jray.json` file for the same item, and is stored independently of the
-media library filesystem.
+**Current:** all three implemented as stated. **Gap:** none.
-### `DELETE /Plugins/JRay/Items/{itemId}/Truth`
+---
-Removes managed truth data for an item (idempotent, always returns `204`).
-The item falls back to its sidecar truth file, if any, on subsequent reads.
-Requires an administrator API key.
+## 4. Work discovery, policy and coverage — JR-015 … JR-019
-### `GET /Plugins/JRay/ClientScript`
+### JR-015 — `GET /Plugins/JRay/Tasks/Pending?limit=10`
-Serves the pause-overlay script that JRay injects into the web client's
-`index.html` (see below). Anonymous access.
-
-### `GET /Plugins/JRay/Tasks/Pending?limit=10`
-
-Lets a remote extraction worker discover what to work on next. Returns a
-random sample (default 10, max 100) of movies/episodes in the library that
-have no truth data yet (neither a managed upload nor a sidecar file):
+A **random** sample (default 10, max 100) of movies and episodes with no truth
+data:
```json
-[
- { "item_id": "abc123-guid", "path": "/data/movies/Movie.mkv", "name": "Movie" }
-]
+[ { "item_id": "abc123-guid", "path": "/data/movies/Movie.mkv", "name": "Movie" } ]
```
-Requires an administrator API key. The sample is random, so repeated polling
-naturally spreads work across the backlog without needing server-side task
-tracking; an empty array means there's nothing left to do (or every remaining
-item is a virtual/missing-path item that JRay can't process).
+Randomness is the design: repeated polling spreads work across the backlog
+without the server tracking who is working on what, and two workers polling
+concurrently mostly do not collide. An empty array means nothing is left, or that
+everything remaining is a virtual/missing-path item.
-**Prioritise/ignore rules apply here.** Items covered by an **ignore** rule are
-never returned. Items covered by a **prioritise** rule are returned ahead of
-un-prioritised items (still randomised within each tier). See
-[Prioritise / ignore rules](#prioritise--ignore-rules) below. Rules only affect
-this work-discovery endpoint — they never change the overlay or the read
-endpoints, so an item you ignore for extraction still shows its overlay if truth
-data happens to exist for it.
+### JR-016, JR-017 — Prioritise / ignore rules
-## Prioritise / ignore rules
+Rules steer the queue: each targets a **genre**, a **series**, or an **item**,
+and either prioritises (front of the queue) or ignores (hidden entirely). This is
+how an admin says "never extract anime", "this series first", or "skip this one".
-Admins can steer the work-discovery queue with a small set of **rules**. Each
-rule targets a **genre**, a **series**, or a single **item**, and either
-**prioritises** (moves matching items to the front of `Tasks/Pending`) or
-**ignores** them (hides them from `Tasks/Pending` entirely). This is how you
-say "never extract anime", "process this series first", or "skip this one
-movie".
-
-Rule resolution for an item picks the **most specific** matching scope:
-`Item` overrides `Series`, which overrides `Genre`. A rule is uniquely keyed by
-its scope + value, and setting a rule for an existing scope+value **replaces**
-it — so a single target can never be both prioritised and ignored. (An item can
-still be pulled in two directions across scopes, e.g. a prioritised series in an
-ignored genre; specificity resolves that — the series rule wins.)
-
-Rules are persisted to `policy.json` under the plugin's configuration directory.
-A rule object:
+Resolution picks the **most specific** match: `Item` > `Series` > `Genre`. A rule
+is keyed by scope + value, and setting one replaces any existing rule for the
+same key — so a single target can never be simultaneously prioritised and
+ignored. Cross-scope conflicts (a prioritised series inside an ignored genre) are
+resolved by specificity: the series wins.
```json
{ "scope": "Genre", "value": "Anime", "action": "Ignore", "label": "Anime" }
```
-- `scope`: `"Genre"`, `"Series"`, or `"Item"`.
-- `value`: a genre name (for `Genre`), a series id GUID (for `Series`), or an
- item id GUID (for `Item`). Genre matching is case-insensitive.
-- `action`: `"Prioritise"` or `"Ignore"`.
-- `label`: optional human-readable label shown in the config UI (informational).
+`value` is a genre name, a series id GUID, or an item id GUID; genre matching is
+case-insensitive. Persisted to `policy.json` in the plugin's configuration
+directory.
-All endpoints below require an **Administrator** API key.
+**JR-017 is the constraint worth stating separately: rules affect work discovery
+only.** They never change the overlay or the read endpoints. An item you ignore
+for extraction still shows its overlay if truth data happens to exist — because
+the rule expresses "don't spend compute here", not "pretend this doesn't exist".
-### `GET /Plugins/JRay/Policy/Rules`
+Endpoints: `GET`/`PUT /Plugins/JRay/Policy/Rules`, and
+`DELETE /Plugins/JRay/Policy/Rules?scope=&value=` (idempotent).
-Returns all configured rules as a JSON array of rule objects.
+### JR-018 — `GET /Plugins/JRay/Coverage`
-### `PUT /Plugins/JRay/Policy/Rules`
-
-Adds or replaces a rule (body is a single rule object). Replaces any existing
-rule with the same `scope` + `value`. Returns `204`, or `400` if `value` is
-empty.
-
-### `DELETE /Plugins/JRay/Policy/Rules?scope={scope}&value={value}`
-
-Removes the rule matching `scope` + `value`. Idempotent, always returns `204`.
-
-## Coverage overview
-
-### `GET /Plugins/JRay/Coverage`
-
-Returns how much of the library has truth data, overall and broken down by
-media type (Film vs TV) and by genre. Requires an **Administrator** API key.
+How much of the library has truth data, overall and by media type and genre:
```json
{
"total": { "total": 1200, "covered": 300, "pending": 850, "prioritised": 40, "ignored": 50 },
- "by_media_type": [
- { "label": "Film", "counts": { "total": 400, "covered": 200, "pending": 190, "prioritised": 10, "ignored": 10 } },
- { "label": "TV", "counts": { "total": 800, "covered": 100, "pending": 660, "prioritised": 30, "ignored": 40 } }
- ],
- "by_genre": [
- { "label": "Anime", "counts": { "total": 120, "covered": 0, "pending": 0, "prioritised": 0, "ignored": 120 } }
- ]
+ "by_media_type": [ { "label": "Film", "counts": { "…": 0 } } ],
+ "by_genre": [ { "label": "Anime", "counts": { "…": 0 } } ]
}
```
-Each `counts` object buckets items as: `covered` (has truth data),
-`pending` (needs processing and not ignored; `prioritised` is the subset of
-`pending` under a prioritise rule), and `ignored` (excluded by an ignore rule).
-`total` is the sum. A useful "percent done" is `covered / (total - ignored)`,
-so ignoring a genre or series does **not** drag the percentage down — ignored
-items are treated as intentionally out of scope.
+`prioritised` is a subset of `pending`. Percent done is
+`covered / (total - ignored)` — **ignoring a genre does not drag the percentage
+down**, because ignored items are intentionally out of scope, not outstanding
+work. An item counts toward every genre it carries, so genre rows overlap and
+need not sum to the library total.
-An item counts toward every genre it carries, so genre rows can overlap and
-their totals need not sum to the library total.
+### JR-019 — Pickers
-### Pickers for the config UI
+`Coverage/Genres`, `Coverage/Series`, and `Coverage/Items?search=&limit=`
+populate the rule editor's dropdowns, each returning
+`[{ "value": …, "label": … }]`. An absent `search` returns `[]`.
-Three helper endpoints populate the rule editor's dropdowns (all require an
-**Administrator** API key, all return `[{ "value": ..., "label": ... }]`):
+**Current (JR-015 … JR-019):** all implemented. **Gap:** none functionally —
+these traced to no requirement until this register existed, which by the gate's
+own definition read as scope creep. They serve PR-003 (fully automatic, no
+per-title manual work): steering a queue is how automation is directed without
+becoming per-title labour.
-- `GET /Plugins/JRay/Coverage/Genres` — distinct genres present on movies/episodes.
-- `GET /Plugins/JRay/Coverage/Series` — series in the library (`value` is the series id).
-- `GET /Plugins/JRay/Coverage/Items?search={term}&limit={n}` — movies/episodes
- whose name matches `term` (`value` is the item id; `limit` default 25, max
- 100). An empty/absent `search` returns `[]`.
+---
-## Client: pushing results from a remote extraction worker
+## 5. Player overlay — JR-020 … JR-024
-A worker that runs the extraction pipeline on a different machine than
-Jellyfin (i.e. it cannot write a `Movie.jray.json` sidecar next to the media
-file) can push results directly over HTTP.
+### JR-020 — The overlay
-To find work, poll `GET /Plugins/JRay/Tasks/Pending?limit=10` (see above) for
-a random batch of items that still need processing, instead of walking the
-whole library and checking each item's `Timeline`/sidecar yourself.
+Jellyfin has no plugin hook for player UI, so jRay adds
+`` to the web client's
+`index.html`. The script listens for the player's pause event, calls `jray?t=`
+for the current item and timestamp, and renders the scene's cast.
-### 1. Authenticate
+Per JR-005 it presents scene membership, not instantaneous visibility.
-Create an **Administrator** API key in Jellyfin (Dashboard → API Keys), and
-send it on every request as either:
+### JR-021 — jRay never injects into `index.html` on disk
-```
-X-Emby-Token:
+**File Transformation is a hard requirement, not a preference. There is no
+on-disk patching fallback.**
+
+The prohibition is on **injection**, not on writing: JR-022's migration must
+write to the file in order to remove a legacy patch. Stating it as "never
+writes" would put the two requirements in contradiction, and the static check
+would have to be disabled to let the migration through — so the check is that no
+code path *adds* the script tag.
+
+At startup jRay looks for the
+[File Transformation](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation)
+assembly via `AssemblyLoadContext` and, if present, calls
+`Jellyfin.Plugin.FileTransformation.PluginInterface.RegisterTransformation` by
+reflection — no compile-time dependency, so jRay loads normally when it is
+absent. The payload:
+
+```json
+{
+ "id": "2c9b5a41-6ad0-4c1e-9f7d-1d1e6b0d5a90",
+ "fileNamePattern": "index.html",
+ "callbackAssembly": "",
+ "callbackClass": "Jellyfin.Plugin.JRay.Services.FileTransformationRegistration",
+ "callbackMethod": "TransformIndexHtml"
+}
```
-or:
+File Transformation matches `callbackAssembly` against `Assembly.FullName`
+exactly, so the full display name is sent. The callback is a public static method
+taking a payload with a `contents` string and returning the transformed string;
+the payload binds with Newtonsoft, which matches property names
+case-insensitively. Registration is unconditional at startup — the callback
+itself checks the "enable overlay" setting per request, so toggling takes effect
+without re-registering.
-```
-Authorization: MediaBrowser Token=""
-```
+Writing to `index.html` is rejected because it is destructive in ways a plugin
+cannot clean up after:
-### 2. Resolve the Jellyfin item id
+- **It outlives the plugin.** Uninstalling jRay leaves the patch in a file jRay
+ no longer owns.
+- **It breaks on upgrade.** A web-client update replaces the file, discarding the
+ patch — or preserves one pointing at an endpoint that has since changed.
+- **It collides.** Another plugin patching the same file races with jRay, and the
+ loser's edit is lost with no diagnostic.
+- **It is a second code path**, and it is the one nobody runs, so it is the one
+ that rots.
-The push endpoint is keyed by the Jellyfin item GUID, not by file path. To
-find it for `Movie.mkv`:
+**Current:** satisfied.
+[`WebClientPatchService`](Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs)
+is removal-only — the injection capability is *deleted*, not switched off, since
+dead code with a live signature is what a later refactor re-enables by accident.
+Enforced by
+[`scripts/checks/no-index-injection.sh`](scripts/checks/no-index-injection.sh),
+which was verified to fail on a reintroduced injection rather than merely to pass
+today. [`README.md`](README.md) no longer advertises a fallback. **Gap:** none.
+
+### JR-022 — Migrate away from earlier on-disk patches
+
+Users upgrading from a version that patched the file must not be left with a
+stale injection. On startup jRay removes any on-disk patch bearing its own
+`` 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: ` or
+`Authorization: MediaBrowser Token=""`.
+
+**2. Find work.** Poll `GET /Plugins/JRay/Tasks/Pending?limit=10` rather than
+walking the library and checking each item.
+
+**3. Resolve the item id.** The push endpoint is keyed by Jellyfin item GUID, not
+path:
```
GET /Items?Recursive=true&Fields=Path&IncludeItemTypes=Movie,Episode
```
-(use `&ParentId=` to narrow the search if the library is large).
-Each returned item DTO has `Id` (the GUID) and `Path`. Match `Path` against
-the absolute path of the file you just processed — note this requires the
-worker to see the file at the *same path* Jellyfin does (same mount/share);
-translate paths first if the worker mounts the library elsewhere.
+Match `Path` against the file you processed — which requires the worker to see
+the file at the *same path* Jellyfin does; translate first if it mounts the
+library elsewhere. The mapping is stable until the file moves, so cache
+`path -> itemId` and re-resolve only on a miss.
-This mapping is stable until the file is moved/re-scanned, so the worker
-should cache `path -> itemId` and only re-resolve on a cache miss.
+**4. Push.** `PUT /Plugins/JRay/Items/{itemId}/Truth` with the truth file body.
+`204` stored (cache invalidated immediately), `400` unsupported
+`schema_version`, `401`/`403` key missing or not an administrator. The `PUT` is
+idempotent, so retrying on a network error is safe.
-### 3. Push the truth file
+**5. Optionally remove.** `DELETE /Plugins/JRay/Items/{itemId}/Truth` always
+returns `204`; the item falls back to its sidecar on the next read.
-```
-PUT /Plugins/JRay/Items/{itemId}/Truth
-Content-Type: application/json
+---
-
-```
+## 11. Open questions
-- `204 No Content` — stored. Takes effect immediately (any cached read for
- this item is invalidated server-side).
-- `400 Bad Request` — `schema_version` is not `1`.
-- `401`/`403` — API key missing or not an administrator.
-
-The `PUT` is idempotent (replaces any existing managed truth for the item),
-so the worker can safely retry on network errors.
-
-### 4. (Optional) Remove pushed data
-
-```
-DELETE /Plugins/JRay/Items/{itemId}/Truth
-```
-
-Always returns `204`. The item falls back to a sidecar `Movie.jray.json` (if
-any) on the next read.
-
-## Web client pause overlay
-
-Since Jellyfin has no plugin hook for player UI, JRay injects
-`` into the web
-client's `index.html` on startup (idempotent, marked with
-``). The injected script listens for the video player's
-pause event, calls `jray?t=` for the current item and timestamp, and renders
-a small overlay listing on-screen actors. This can be disabled via the
-plugin's "Enable pause overlay" setting, which also removes the injected
-script.
+1. **`sample_fps`, `gallery_size` and `pipeline_version` move under
+ `extraction.*` in JR-002.** This aligns the truth file with the Jmanifest's
+ block of the same name, and the bump is breaking regardless. It is a change
+ this spec proposes rather than one inherited from SR-003's list — confirm, or
+ keep them top-level.
+2. **Does `route` belong in the `jray?t=` envelope?** JR-013 says the response is
+ extensible and JR-005 says presentation must not over-claim. Exposing belief
+ and route would let the overlay caveat a weak claim, but invites a UI that
+ shows a number to a viewer who cannot act on it.
+3. **System open question 2 — unidentified presence.** If published, the overlay
+ could show "unidentified person" and JR-046 gets its queue from the truth file
+ directly. jRay is the consumer that would have to display it, so this repo has
+ a position to state.
+4. **Test-ID namespacing.** `UT-nnn`/`IT-nnn` are per-component registers, so
+ `UT-001` will exist in both this repo and `scene-actor-extraction`. Fine while
+ the gate runs per repo; ambiguous the moment a rollup spans them.
diff --git a/docs/requirements.md b/docs/requirements.md
new file mode 100644
index 0000000..97c0697
--- /dev/null
+++ b/docs/requirements.md
@@ -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 `` 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 | `