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 | `