The signature had a producer and a reader but no consumer, so nothing ever fingerprinted anything. `ManifestAligner` runs on the fetch path, before the windows are stored. A local alignment supersedes the server's offset. The server has never seen this file — its offset is a runtime-difference inference at best, while the local comparison is against the media the windows will actually be drawn over. It also needs no round trip, so no signature leaves the instance. This is what jRay's spec already meant by matching being a consumer concern: the server never rewrites a manifest, so one stored manifest serves every trim of the same cut. The offset has two terms and only one is in the server's pseudocode. Both windows are centred on their own file's midpoint, so unequal runtimes start them at different absolute times; a release with 40 s of extra head material recovers 20 s from the slide and 20 s from the anchor difference. Using the slide alone is wrong by half the runtime difference on every shifted release. Degradation, never failure. Signatures off, no manifest signature, media under the window, a `v2:` producer, a missing binary, a decode error — each applies the server's offset rather than refusing, because a signature is an enhancement to cut matching and must never break a fetch. "Un-comparable" and "does not match" are kept distinct, which a test caught: `Compare` returns null for both, and conflating them would report a 90-second extra as content disagreeing with its own manifest. A genuine disagreement is stored anyway — the audio may legitimately differ, a different language track being the obvious case — and surfaced as a caveat that outranks the tier's, since it is the stronger statement. The applied offset, score, slide and the local file's own signature are written beside the truth file: the offset is otherwise unrecoverable once the windows are shifted, and the stored signature lets a later fetch align without decoding again. Provenance is never injected into the truth file, so the bytes served back stay the producer's (JR-004). `docs/audio-alignment.md` documents the mechanism end to end. TRACES: JR-047 | SR-003
411 lines
35 KiB
Markdown
411 lines
35 KiB
Markdown
# 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 |
|
||
|
||
## Tests (UT)
|
||
|
||
| ID | Asserts | Covers | Status |
|
||
|---|---|---|---|
|
||
| UT-001 | Marked tag **and its trailing newline** removed — no blank line accumulates per upgrade cycle | JR-022 | **Passing** |
|
||
| UT-002 | Marked tag with no trailing newline removed | JR-022 | **Passing** |
|
||
| UT-003 | Document without the marker left byte-identical | JR-022 | **Passing** |
|
||
| UT-004 | Removal is idempotent — startup runs it on every boot forever after | JR-022 | **Passing** |
|
||
| UT-005 | **Another plugin's injection left intact** — it is their file too | JR-022 | **Passing** |
|
||
| UT-006 | An unmarked look-alike script tag is left alone — JRay did not write it | JR-022 | **Passing** |
|
||
| UT-007 | No matching rule resolves to `null` | JR-016 | **Passing** |
|
||
| UT-008 | Item rule beats Series rule | JR-016 | **Passing** |
|
||
| UT-009 | **Prioritised series inside an ignored genre — series wins** | JR-016 | **Passing** |
|
||
| UT-010 | Genre matching is case-insensitive | JR-016 | **Passing** |
|
||
| UT-011 | A Series rule valued `Guid.Empty` does not swallow every movie | JR-016 | **Passing** |
|
||
| UT-012 | `TryRegister` returns `false` when File Transformation is absent | JR-023 | **Passing** |
|
||
| UT-013 | …and **warns** naming the install URL, with no "falling back" claim | JR-023 | **Passing** |
|
||
| UT-014 | Overlay disabled ⇒ `index.html` returned unchanged | JR-023 | **Passing** |
|
||
| UT-015 | Null contents return empty rather than throwing — this callback runs on every page another plugin serves | JR-023 | **Passing** |
|
||
| UT-016 | Both bounds **inclusive** — start, interior and end all present | JR-005 | **Passing** |
|
||
| UT-017 | Just outside either bound is absent | JR-005 | **Passing** |
|
||
| UT-018 | A zero-length window is a real sighting, not a degenerate one to discard | JR-005 | **Passing** |
|
||
| UT-019 | **Overlapping windows** — present inside an enclosing window | JR-005 | **Passing** |
|
||
| UT-020 | **Unsorted windows still resolve**; sortedness is a producer guarantee, not a correctness dependency | JR-006 | **Passing** |
|
||
| UT-021 | **Adjacent windows are never merged** — reported once, from two windows | JR-004 | **Passing** |
|
||
| UT-022 | Truth file round-trips with windows byte-identical | JR-004 | **Passing** |
|
||
| UT-023 | 50 actors × 1000 windows: response bounded by actor count, lookup not quadratic | JR-006 | **Passing** |
|
||
| UT-024 | A fetched claim round-trips: server, tier, **offset**, caveat, timestamp | JR-010 | **Passing** |
|
||
| UT-025 | A local push records no server and **no tier** — there is no cut to match | JR-010 | **Passing** |
|
||
| UT-026 | Provenance is **not** written into the truth file | JR-010, JR-004 | **Passing** |
|
||
| UT-027 | `Delete` removes provenance too — no record outliving its claim | JR-010 | **Passing** |
|
||
| UT-028 | Unknown item yields null rather than a fabricated record | JR-010 | **Passing** |
|
||
| UT-029 | A v2 file round-trips; `extraction.*` and `cut.*` survive intact — `sample_fps` read from the block, not the top level | JR-002 | **Passing** |
|
||
| UT-030 | `scenes` objects retain **belief and route** — a window that loses them is indistinguishable from v1 | JR-002 | **Passing** |
|
||
| UT-031 | All three routes (`live`, `deferred`, `pooled`) survive a round trip | JR-002 | **Passing** |
|
||
| UT-032 | A window without belief reads `null`, **not `0.0`** — absent and disbelieved are different claims | JR-002 | **Passing** |
|
||
| UT-033 | `schema_version` 1, 3 and 0 are all **refused**, and the message names the version found | JR-003 | **Passing** |
|
||
| UT-034 | A **missing** `schema_version` is refused, never assumed current | JR-003 | **Passing** |
|
||
| UT-035 | A `null` truth file is refused without throwing | JR-003 | **Passing** |
|
||
| UT-036 | **A v1 file never half-parses into usable windows** — it either fails to deserialise or is stopped by the gate | JR-003 | **Passing** |
|
||
| UT-037 | **The producer's actual output parses** — the exact shape `result_sink_node.hpp` writes, omitting `cut` and two `extraction` fields, not the spec's fully populated example | JR-002 | **Passing** |
|
||
| UT-038 | The **regenerated** fixture PCM matches the recorded `s16le` and `f32le` checksums — the input is proven identical before any DSP claim is made | JR-043 | **Passing** |
|
||
| UT-039 | **Signature equals the shared golden vector, byte for byte** — the same string the C++ producer emits | JR-042, JR-043 | **Passing** |
|
||
| UT-040 | Band → FFT-bin table matches the recorded one, and tiles 300–3000 Hz contiguously with no empty band | JR-042 | **Passing** |
|
||
| UT-041 | Well-formed: `v1:` prefix, 1288 frames, bit 7 always clear, **and the fixture still exercises all 32 bands and all 4 energy classes** | JR-042 | **Passing** |
|
||
| UT-042 | Whole frames only — 4095 samples yield nothing, 5120 yield two; a partial frame is never padded into a signature | JR-042 | **Passing** |
|
||
| UT-043 | The **real FFmpeg decode** of the fixture reproduces the golden signature | JR-042 | **Passing** (needs a binary) |
|
||
| UT-044 | **The window is taken from the centre**: the fixture wrapped in 90 s of silence either side signs identically | JR-042 | **Passing** (needs a binary) |
|
||
| UT-045 | **The 120 s boundary, on one file**: runtime 119.999 emits nothing, runtime 120.000 emits the golden signature — only the runtime differs, so a null cannot be blamed on the decode | JR-044 | **Passing** (boundary needs a binary) |
|
||
| UT-046 | Two identical, perfectly valid signatures still yield **no match and no offset** when either runtime is under the window — the rule is read off the runtime, not inferred from a missing string | JR-044 | **Passing** |
|
||
| UT-047 | A `v2:` signature whose payload is byte-identical to a valid v1 one is **refused, not parsed** — by the matcher as well as the parser | JR-045 | **Passing** |
|
||
| UT-048 | A v1 signature parses to exactly the produced frames, checked against the **golden vector** rather than against the producer's own output | JR-045 | **Passing** |
|
||
| UT-049 | Missing prefix, empty payload, invalid base64 and a **set reserved bit** are each refused without throwing — the client never accepts what the server would reject | JR-045 | **Passing** |
|
||
| UT-050 | Identical signatures score 1.0 at offset 0 and reach the `audio` tier | JR-044 | **Passing** |
|
||
| UT-051 | **A shifted release recovers its offset** rather than failing to match — the case the feature exists for | JR-044 | **Passing** |
|
||
| UT-052 | Unrelated content yields **no match at all**, and runtime skew contributes its window-anchor term to the offset | JR-044 | **Passing** |
|
||
| UT-053 | **A local alignment supersedes the server's offset**, and the server's claim is retained rather than overwritten | JR-047 | **Passing** |
|
||
| UT-054 | The local signature is recorded, so a later fetch aligns without decoding the media again | JR-047 | **Passing** |
|
||
| UT-055 | Every unavailable local path — off, no manifest signature, neither, short media, **and a `v2:` producer** — falls back to the server rather than refusing | JR-047 | **Passing** |
|
||
| UT-056 | Two signatures that genuinely disagree are recorded as a mismatch and **still do not break the fetch** | JR-047 | **Passing** |
|
||
| UT-057 | A mismatch **outranks the tier** in the caveat shown to the user — a `runtime` match would otherwise show nothing at all | JR-047 | **Passing** |
|
||
|
||
All execute and pass. UT-043 and UT-044 are the two that need an FFmpeg binary,
|
||
which the plugin gets from Jellyfin at run time and a bare CI container may not
|
||
have; they self-skip without one. That is why the cross-repo claim rests on
|
||
UT-038 and UT-039, which regenerate the fixture PCM from `make_fixture.py`'s
|
||
arithmetic and need no codec at all — a check that skips is not a check.
|
||
|
||
UT-045 follows the same rule: its *below-the-boundary* half is codec-free and
|
||
always binds, because the runtime check short-circuits before the encoder is
|
||
consulted — asserted with a deliberately invalid encoder path, so passing proves
|
||
the short-circuit rather than merely a failed decode.
|
||
|
||
The suite is also checked to **fail** on deliberate mutations, because a suite
|
||
that has only ever passed is not evidence that it tests anything. Each was
|
||
restored and re-verified afterwards:
|
||
|
||
| Mutation | Fails | Blast radius |
|
||
|---|---|---|
|
||
| Drop the newline-stripping in `RemoveInjection` | UT-001 | 1 test |
|
||
| Downgrade the missing-dependency warning to `Information` | UT-013 | 1 test |
|
||
| Make the window end bound exclusive (`t < end`) | UT-016, UT-018 | 2 tests |
|
||
| Stop `Delete` removing provenance | UT-027 | 1 test |
|
||
| Make `TruthSchema.IsSupported` accept any version | UT-033, UT-034, UT-035, UT-036 | 4 tests |
|
||
| Emit `v2:` as the signature prefix | UT-039, UT-043, UT-044 | 3 tests |
|
||
| Aggregate a band by **sum** instead of mean | UT-039, UT-043, UT-044 | 3 tests |
|
||
| Anchor the decode window at the head instead of the centre | UT-044 | 1 test |
|
||
|
||
Two further mutations were tried and **did not fail**, which is worth recording
|
||
rather than hiding: the symmetric `N-1` Hann window in place of the periodic one,
|
||
and the lower median in place of the upper as the energy reference. Both are
|
||
pinned by prose in the shared fixture, and on this synthetic vector neither moves
|
||
a peak bin or crosses an energy-class edge. They are conventions the golden
|
||
vector does not police, so a second implementation could get either wrong and
|
||
still pass — the fixture would need frames sitting nearer those boundaries to
|
||
catch it.
|
||
|
||
The window-bound mutation is the one worth keeping: a single character turns an
|
||
inclusive window into a half-open one, which would drop an actor at exactly the
|
||
moment a scene ends — and nothing else in the suite would have noticed.
|
||
|
||
`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 | **Done** (UT-029…032) — `TruthScene`, `TruthExtraction`, `TruthCut`; `anneal_sec` and top-level `sample_fps` deleted, not zeroed. `ManifestConverter` carries belief, route and both blocks through |
|
||
| JR-003 | Reject an unknown `schema_version`, never guess. **Flag day: v2 only**, no dual-accept | SR-003 | High | **Done** (UT-033…036) — `TruthSchema.IsSupported` is the single gate, applied on all four paths: sidecar read, managed store load, managed `PUT`, converted manifest. Rejections name the file and the version found |
|
||
| JR-004 | A window is a **scene-membership claim**, not a recognition event — never reinterpreted, merged, split or trimmed | **SR-002** | High | **Done** (UT-021, UT-022) |
|
||
| JR-005 | Query semantics: actor present at `t` if any window contains `t`; presentation must not assert instantaneous visibility | **SR-002** | High | **Done** (UT-016…019) |
|
||
| JR-006 | Read path holds up under **numerous** windows — no assumption of a handful of long ones | SR-002 | Medium | **Done** (UT-020, UT-023) |
|
||
| 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 | **Done** (UT-024…028) |
|
||
| 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** (UT-007…011) |
|
||
| JR-017 | Rules steer **work discovery only** — never the overlay or the read endpoints | PR-003 | Medium | Done |
|
||
| JR-018 | Coverage report by media type and genre; ignored items leave the percent-done denominator rather than dragging it down | PR-003 | Medium | Done |
|
||
| JR-019 | Picker endpoints (genres, series, item search) populate the rule editor | PR-003 | Low | Done |
|
||
|
||
## Player overlay (JR-020 … JR-024)
|
||
|
||
| ID | Requirement | Traces to | Priority | Status |
|
||
|---|---|---|---|---|
|
||
| JR-020 | Pause overlay: injected client script queries `jray?t=` and renders the scene's cast | **PR-001** | High | Done |
|
||
| JR-021 | **jRay never injects into `index.html` on disk.** File Transformation is a hard dependency; there is no on-disk fallback. The only permitted write is JR-022's removal | PR-004 | High | **Done** |
|
||
| JR-022 | Migration: remove any on-disk patch left by an earlier jRay, identified by the `<!-- jray-overlay -->` marker | PR-004 | High | **Done** (UT-001…006) |
|
||
| 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 | **Done** (UT-012…015; config page is T4) |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
| JR-028 | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB bundle — aborting rather than buffering | SR-004 | High | Done |
|
||
| JR-029 | HTTPS required for non-loopback servers; certificate validation must not be disabled | SR-004 | High | Done |
|
||
| 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 | Done |
|
||
| 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 | Done |
|
||
|
||
## 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, JR-047)
|
||
|
||
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.
|
||
|
||
JR-042 … JR-045 are the signature itself, produced and read. **JR-047 is what
|
||
uses it**: without a consumer on the fetch path the other four are a fingerprint
|
||
nothing ever fingerprints. See [`audio-alignment.md`](audio-alignment.md) for the
|
||
end-to-end mechanism.
|
||
|
||
| 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 | **Done** (UT-039…044) — `AudioSignature` is the DSP, `AudioSignatureService` the decode; FFmpeg is invoked as a child process for decode, downmix and resample, and nothing was added to the project's dependencies |
|
||
| JR-043 | Golden-vector fixture **shared with the extraction repo**, proving the two implementations are bit-exact | SR-003 | High | **Done** (UT-038, UT-039) — `fixtures/audio/` holds the extraction repo's three files byte-identically; the C# signature equals the recorded vector exactly |
|
||
| JR-044 | Media shorter than 120 s: emit no signature and apply no sync offset — identical rule in both producers | SR-003 | Low | **Done** (UT-045, UT-046, UT-050…052) — the producer half was already in `AudioSignatureService`; the consumer half needed a reader, so `AudioSignatureMatcher` implements the §3 slide and declines an offset outright below the window. The boundary is asserted on one file at 119.999 s and 120.000 s |
|
||
| 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 | **Done** (UT-047…049) — `AudioSignatureMatcher.TryParseFrames` refuses any prefix but `v1:`, and refuses malformed or structurally invalid payloads, so a future producer's `v2:` drops the item to the runtime tier instead of scoring as if it were understood |
|
||
| JR-047 | **A fetched manifest is aligned against the local file before its windows are stored**, and the alignment is recorded beside the truth data | SR-003 | High | **Done** (UT-053…057) — `ManifestAligner` runs on the fetch path. A local alignment supersedes the server's offset, since the server has never seen this file; every unavailable path degrades to the server's offset rather than refusing, and a genuine signature disagreement is recorded and surfaced as a caveat without failing the fetch |
|
||
|
||
## 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.
|
||
|
||
**It carries tiers (T2 + T4) despite being undesigned, and stays in the coverage
|
||
denominator.** Tiers say *how* it will be verified, which is knowable — an
|
||
association endpoint is CI-testable, the UI is not — without asserting *what*
|
||
the assertions are, which is not. Recording it as T4-only would have been the
|
||
tempting move, because that drops it out of CI scope and lifts the CI
|
||
percentage; it would also have been the 158%-coverage error in miniature, a
|
||
number improved by reclassifying work rather than by doing it. An unbuilt
|
||
requirement should count against coverage until it is built.
|
||
|
||
---
|
||
|
||
## 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.** Executable tiers are declared per repo in
|
||
[`../traceability.toml`](../traceability.toml), so the numbering is a local
|
||
choice — but jRay keeps **T4** for "no CI host can run this" because that is
|
||
what T4 means in `scene-actor-extraction`. A tier number should mean the same
|
||
thing when read across repos; reusing T3 for a live tier here would make a
|
||
cross-repo reader count live-only requirements as covered.
|
||
|
||
`Jellyfin.Plugin.JRay.Tests` (xUnit, in the solution) carries the T1 tier. It
|
||
builds clean alongside the plugin and all 15 tests pass.
|
||
|
||
### Running the suite on a box without the web runtime
|
||
|
||
`dotnet test` from the repo root. Two properties on the test project make that
|
||
work anywhere, and both are load-bearing rather than incidental:
|
||
|
||
- **`RollForward=LatestMajor`.** The plugin targets `net9.0` to match Jellyfin's
|
||
ABI, but a machine that can *build* it need not have the 9.0 runtime. Rolling
|
||
the test host forward keeps the suite runnable without pinning developers to a
|
||
runtime the plugin does not otherwise need.
|
||
- **`DisableTransitiveFrameworkReferences=true`.** The plugin
|
||
framework-references `Microsoft.AspNetCore.App` through `Jellyfin.Controller`,
|
||
and that flows into anything referencing it — so the test host would otherwise
|
||
demand a web runtime that no version of exists in the Arch repositories for
|
||
.NET 9 (8 and 10 only).
|
||
- **Explicit `Jellyfin.Controller` / `Jellyfin.Model` references.** The plugin
|
||
sets `ExcludeAssets=runtime` on both, because at run time the *server* supplies
|
||
them and shipping copies would risk loading a second, different
|
||
`MediaBrowser.Common`. The test host is not the server, so it must bring its
|
||
own — hence the same packages without that exclusion, and only in the test
|
||
project.
|
||
|
||
Those two together are what make it work: the first drops the demand for the web
|
||
*framework*, the second supplies the Jellyfin *assemblies*. Cutting the framework
|
||
reference alone is not enough — `ILogger` and `MediaBrowser.Common` live in the
|
||
excluded assets, so anything beyond genuinely dependency-free logic fails to load
|
||
with `FileNotFoundException` at run time rather than at build.
|
||
|
||
**T2 — controllers and authorisation — is still a separate matter**, since
|
||
instantiating MVC types needs the ASP.NET Core runtime itself, not just its
|
||
reference assemblies. Those tests belong in a second project that keeps the
|
||
framework reference and leans on `RollForward` to reach the 10.0 runtime.
|
||
|
||
### 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 | All three route values; belief absent reads `null` not `0.0`; **the producer's real output**, which omits `cut` and two `extraction` fields — a test written only against the spec's populated example would have passed throughout the break |
|
||
| JR-003 | **T1** | `schema_version` 1 and 3 are both **rejected**, not coerced | Missing field entirely; `null` document; **a v1 file must not half-parse into usable windows** |
|
||
| 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 | Response bounded by actor count, not window count; lookup not quadratic | 50 × 1000 windows; **unsorted input still resolves** — sortedness is a producer guarantee, never a correctness dependency |
|
||
| 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 a round trip and is deleted with its truth | Fetched vs pushed for the same item; **provenance never inside the truth file**; unknown item yields null |
|
||
| 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, and the log says so | Detection and log content covered by UT-012…015; the config-page banner is T4, verifiable only against a live server |
|
||
| JR-024 | T1 | A name containing markup renders escaped | `<script>` in an actor name from a hostile server |
|
||
| JR-025 | T1 | First result clearing the tier wins; disabled servers skipped | All servers fail; first server returns a below-tier match |
|
||
| JR-026 | T1 | Server 2 queried only for episodes server 1 lacked | Bundle with a gap in the middle of a season |
|
||
| JR-027 | T1 | Unknown field, oversized body, and out-of-range window each rejected | Window ending beyond the item's runtime |
|
||
| JR-028 | T1 | Stream aborts past the cap rather than buffering | Server declaring a small length and sending more |
|
||
| JR-029 | T1 | Plain `http` to a non-loopback host is refused | `http://localhost` allowed; `http://192.168.x` refused |
|
||
| JR-030 | **T1** | Offset added to every window before storage | Negative offset; offset that would push a window below zero |
|
||
| JR-031 | T2 | All four routes exist and are admin-only | — |
|
||
| JR-032 | T1 | `Identify` returns candidates and stores nothing | A single high-confidence candidate still does not auto-store |
|
||
| JR-033 | T1 | Sweep batches through `exists` and paces | Backlog smaller than one batch |
|
||
| JR-034 | **T1** | `movie` and `jellyfin_id` absent from the upload body | Contribution attempted to a `FetchOnly` server must not send |
|
||
| JR-035 | T1 | `Expect: 100-continue` set on uploads | — |
|
||
| JR-036 | T1 | Below-tier match is not stored; `loose` is flagged | Tier configured to `audio` with only a `runtime` match available; **`MatchTier` has no `Exact` member** — the file-hash tier is withdrawn on legal grounds, so a test naming it would not compile |
|
||
| JR-037 | T1 | Failing server skipped, backoff grows | Every server failing must not hang the sweep |
|
||
| JR-038 | **T1** | Every exchange switch defaults off; community server disabled | Fresh config object, no user input |
|
||
| JR-039 | T1 | Batch never exceeds 100 items | Library of 10⁴ items produces a paced sweep |
|
||
| JR-040 | **T4** | Config page states the per-server exposure | Manual review of copy |
|
||
| JR-041 | **static** | No embedding or image field is parsed or stored | Grep-based, mirroring the server's UR-012 |
|
||
| JR-042 | T1 | DSP chain matches the specified parameters exactly | Window, hop, band, bin count each asserted individually; the decode itself is covered only where an FFmpeg binary exists, so it must not be the only cover for any claim |
|
||
| JR-043 | **T1** | Signature matches the shared golden vector **bit-for-bit** | The fixture PCM is regenerated from `make_fixture.py` and checked against the recorded decode checksums first, so the check binds on a host with no codec and a decode divergence is distinguishable from a DSP one |
|
||
| JR-044 | T1 | Media < 120 s yields no signature and no offset | Exactly 120 s — the boundary both repos must agree on |
|
||
| JR-045 | T1 | `v1:` emitted; an unknown prefix is refused, not parsed | `v2:` signature from a future producer |
|
||
| JR-047 | **T1** | A fetched manifest is aligned locally before storage, and the alignment is recorded | **Every way the local path can be unavailable must degrade to the server's offset, never refuse** — signatures off, no manifest signature, media under the window, a `v2:` producer. Distinguish those from a genuine mismatch: a 90 s extra is not content that disagrees with its manifest, and telling a user it is would be worse than saying nothing |
|
||
| JR-046 | T2 + **T4** | *Assertions deferred* — recording an association and persisting it is T2; the review UI itself is T4 | Cannot be written until the truth-file interface for unidentified presence is settled (system open question 2) and AR-021/AR-022 land |
|
||
|
||
Three are worth singling out. **JR-021** and **JR-041** are static checks because
|
||
both are requirements to *not do something*, and a prohibition is verified by
|
||
absence, not by a passing test. **JR-043** is the cross-repo check: it is the only
|
||
test in this repo whose fixture is shared with another, and it is CPU-only DSP,
|
||
which is exactly why it can be the binding check rather than an aspiration.
|
||
|
||
---
|
||
|
||
## Running the gate
|
||
|
||
The extractor is shared and vendored, never forked — there must only ever be one
|
||
implementation. Everything that varies per repo lives in
|
||
[`../traceability.toml`](../traceability.toml), so the invocation carries no
|
||
flags to drift out of sync between a developer's shell and CI:
|
||
|
||
```sh
|
||
python3 scripts/vendor/jray-project/scripts/traceability/extract_traces.py \
|
||
--root . --format coverage
|
||
```
|
||
|
||
That config declares the `JR` prefix, the languages, the source roots, the
|
||
CI-executable tiers, and the path to the vendored system spec.
|
||
|
||
Refresh the pinned tooling with
|
||
`git submodule update --remote scripts/vendor/jray-project`.
|
||
|
||
Two choices in it are worth knowing about. `scripts/checks` is scanned so the
|
||
**static checks carry their own TRACES tags** — an enforcement script is
|
||
evidence for a requirement exactly as a unit test is. And the source roots are
|
||
listed individually rather than as `scripts`, because the latter would walk
|
||
`scripts/vendor/jray-project` and harvest the `AR-nnn` examples in the
|
||
extractor's own docstrings as orphan tags.
|
||
|
||
`JR` is now what the shared tooling expects too — its example config names
|
||
`jRay: ["JR"]` — so the prefix is settled across all three repos. It was chosen
|
||
because `JRay-public-server` already ships `UR-001…018` and `DR-001…014`, and a
|
||
second repo reusing those prefixes would make `UR-007` ambiguous across
|
||
registers, which is precisely the ID the server's own register asks this one to
|
||
cross-reference (see JR-025).
|