Author SHA1 Message Date
dtourolle 55b4633324 chore(traces): regenerate the traceability report 2026-08-04 14:16:25 +02:00
dtourolle 8c5fb5950d docs: the matcher implements a superseded version of §3
JRay-public-server UR-009 changed the §3 score to count a frame as
agreeing within ±1 frame, on scene-actor-extraction VR-014's measurement:
over 40 correctly recovered offsets on real film audio the exact-frame
rule scored 27 of them below 0.85 and demoted them to `loose`, because
the two windows are cut on their own file's frame grid and those grids
do not coincide.

`AudioSignatureMatcher` still implements the exact rule. Nothing is
misaligned by the divergence — the recovered offset is unaffected, and
JR-047 makes the local answer supersede the server's — but the plugin
will show a caveat for a match that is not in doubt. Recorded against
JR-044 rather than fixed here, so the gap is findable.

TRACES: JR-044 | SR-003
2026-07-31 22:43:37 +02:00
dtourolle 1e247c4c7d feat(audio): align a fetched manifest to the local file before storing
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
2026-07-31 16:52:21 +02:00
dtourolle e8ce779ad3 docs: land the registers for schema v2 and the audio signature
Status and verification rows for the six requirements landed in this
branch, plus UT-029 … UT-052, and the generated traceability matrix that
`docs/traceability.md` holds in the other two components but was missing
here. `traces-report.json` is gitignored to match the server: the matrix
is committed, the JSON report is not, since nothing reads it back.

Two corrections rather than additions:

`AudioSignatureTests` was tagged UT-029 … UT-035, IDs the register had
already assigned to the schema tests. Renumbered to UT-038 … UT-044,
matching the register, which was right — duplicate IDs defeat the point
of IDs being permanent.

`ReadCappedAsync` implements JR-028's response cap and carried no tag,
so the requirement read as uncovered.

The gate reports 0 orphan tags.

TRACES: JR-002, JR-003, JR-028, JR-041, JR-042, JR-043, JR-044, JR-045 | SR-003
2026-07-31 16:24:40 +02:00
dtourolle 8b430d53c0 test(gallery): verify the no-gallery-data prohibition by absence
JR-041 is a requirement to *not* do something, so like JR-021 it can only
be verified by absence. Mirrors the server's UR-012, which is preserved
the same way.

The check looks for any *parse* of gallery data — a field name, a
property, a type — not merely for network calls: an embedding arriving
here would mean SR-005 had already been breached upstream.

TRACES: JR-041 | SR-005
2026-07-31 16:24:28 +02:00
dtourolle c863fe85f5 feat(audio): signature reader, offset recovery, and version refusal
Closes the consumer halves of JR-044 and JR-045, which were blocked on
there being no reader at all. `AudioSignatureMatcher` implements the
specification's slide — ±600 frames, scoring the fraction of overlapping
frames whose peak band agrees — and returns the tier and offset.

JR-045: `TryParseFrames` refuses any prefix but `v1:`. A `v2:` signature
from a future producer describes a DSP chain this build does not
implement, so scoring it as v1 would be a confident wrong answer where
declining is a correct one — the item drops to the runtime tier, which is
the entire reason the prefix is separate from `schema_version`.

JR-044: a runtime under 120 s yields no match and therefore no offset,
read off the runtime rather than inferred from a missing string, because
the runtime is what both producers test. The boundary is asserted on one
file at 119.999 s and 120.000 s, so a null cannot be blamed on the decode.

The offset has two terms, which is easy to miss: the recovered slide, and
the difference between where the two windows are anchored, since both are
centred on their own file's midpoint. A release carrying 40 s of extra
head material recovers 20 s from each.

One parameter is not from the specification and is marked as such in the
code: an alignment must overlap by at least 64 frames before its score
counts, or the extreme offsets compare a handful of frames where a chance
agreement scores 1.0 and beats the true alignment.

TRACES: JR-044, JR-045 | SR-003
2026-07-31 16:24:23 +02:00
dtourolle 17be9bcc4e feat(audio): v1 signature producer, bit-exact with the pipeline
`AudioSignature` is the DSP — band table, periodic Hann, radix-2 FFT,
band-mean peak, energy class, packing — and `AudioSignatureService` the
decode, running the FFmpeg binary `IMediaEncoder.EncoderPath` names. The
plugin gained no dependency.

The server specification's prose does not determine a byte stream, so the
parameters it leaves open are pinned by the fixture shared with the
extraction repo and restated at the top of `AudioSignature`: double
throughout, whole frames only, periodic Hann, unnormalised FFT, band mean
rather than sum, argmax ties to the lowest index.

`fixtures/audio/` holds the extraction repo's three files byte-identically
and the computed signature equals the recorded vector exactly. The binding
check regenerates the fixture PCM from `make_fixture.py`'s arithmetic and
verifies it against the recorded decode checksums, so it runs on a host
with no codec at all and a decode divergence stays distinguishable from a
DSP one; the two tests that drive real FFmpeg self-skip without a binary.

The workflow named "Test Plugin" until now only compiled one. A test that
is built and never run is not evidence, and a golden vector shared across
two repos exists precisely so CI fails when they drift.

TRACES: JR-042, JR-043 | SR-003
2026-07-31 16:24:11 +02:00
dtourolle 19aecee646 feat(truth): schema_version 2 read path, v2 only
Replaces the v1 shape rather than accepting both. `anneal_sec` and the
top-level `sample_fps` are deleted, not zeroed; `extraction` and `cut`
blocks arrive; `scenes` become objects carrying belief and route, so a
window records how far to trust it instead of being a bare float pair.

`TruthSchema.IsSupported` is the single gate and is applied on all four
read paths — sidecar, managed store load, managed PUT, and converted
manifest. Previously only the controller checked, so the version the
plugin claimed to require and the one it would actually parse were free
to drift. Rejections name the file and the version found, so an item that
looks empty is distinguishable from one that was refused.

`ManifestConverter` carries belief, route and both provenance blocks
through: dropping them would silently downgrade every fetched manifest
against a locally extracted one.

TRACES: JR-002, JR-003 | SR-003
2026-07-31 16:23:58 +02:00
Gitea Actions 596566813c Update manifest.json for latest build (5152f6f) 2026-07-31 09:37:36 +00:00
dtourolleandClaude Opus 5 5152f6f129 JR-010: record how truth data was obtained
🏗️ Build Plugin / build (push) Successful in 31s
Latest Release / latest-release (push) Successful in 43s
🧪 Test Plugin / test (push) Successful in 27s
Three routes now deliver truth data -- a sidecar, a worker push, and a fetched
manifest -- and once stored they were indistinguishable. The truth file records
nothing about how it arrived, so a locally computed sidecar and a loose-tier
manifest from a third-party server looked identical to every reader, despite
making claims of very different strength about the same item.

TruthProvenance records source, server, match tier, applied offset and caveat.
GET /Items/{itemId}/Provenance serves it, and JR-036's loose-tier caveat now
has somewhere to come from.

Two decisions carried in the code rather than assumed:

Provenance is stored BESIDE the truth file, never inside it. Injecting fields
would mean the bytes served back are not the bytes the producer wrote, which is
the property JR-004 turns on. UT-026 pins it by asserting the stored truth JSON
contains no provenance keys.

The applied offset is recorded because it is otherwise unrecoverable. Once
JR-030 shifts every window the timings look native, and nothing else would say
they had been shifted -- which matters when diagnosing an overlay that is
consistently a few seconds out.

A sidecar's provenance is derived rather than stored: it is local, and its
timestamp is the file's own. Precedence resolves through the same rule as
GetTruthAsync, because resolving it twice by different rules is how the two
would drift.

Fourth mutation check: stopping Delete from removing provenance fails UT-027
alone -- a stale record would otherwise outlive its claim and describe data the
next fetch had already replaced.

TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:36:08 +02:00
dtourolleandClaude Opus 5 c04d5a3dcc JR-004, JR-005, JR-006: scene-scoped read path
Presence was decided by a LINQ predicate inline in the controller, so the
semantics SR-002 sets were nowhere stated in code -- the read path complied by
accident rather than by requirement. PresenceLookup is now the unit that
decides, tagged, with the reasoning next to it.

JR-004: windows are served exactly as given. UT-021 pins that [0,10] and
[10,20] are not merged despite looking mergeable -- two windows mean a genuine
departure and return, and collapsing them answers a different question from the
one the truth file asked. UT-022 pins a byte-identical round trip.

JR-005: bounds inclusive at both ends, zero-length windows are real sightings
rather than degenerate ones to discard, overlaps resolve.

The wording was the larger half of JR-005. The overlay rendered a bare list: it
asserted nothing, but told the viewer nothing either, and the default reading of
a paused frame is "these people are on screen" -- exactly what SR-002 forbids.
It now carries an "In this scene" heading. ActorAtTime became ActorInScene, and
README no longer contains "on screen" anywhere; it stated the forbidden reading
outright in seven places, including the opening sentence.

JR-006: measured rather than assumed. UT-023 builds 50 actors x 1000 windows and
asserts the response is bounded by actor count, never window count. The lookup
is a full scan on purpose -- an early exit on `start > t` would exploit the
sortedness the format requires, but would silently under-report the moment one
producer emitted windows out of order. UT-020 pins that unsorted input still
resolves; WindowsAreSorted is a diagnostic, not a correctness dependency.

Third mutation check: making the end bound exclusive fails UT-016 and UT-018 and
nothing else. One character turns an inclusive window into a half-open one,
dropping an actor at exactly the moment a scene ends.

TRACES: UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023
TRACES: JR-004, JR-005, JR-006 | SR-002

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 11:29:46 +02:00
dtourolleandClaude Opus 5 305b898b15 JR-046: record verification tiers for the undesigned review UI
The gate warned on every run that JR-046 had no tier and was being counted as
CI-executable by default. It is the one TBD requirement in the register --
undesigned, pending AR-021/AR-022 and system open question 2 -- so it had no
verification plan to point at.

Tiers say *how* it will be verified, which is knowable: an association endpoint
is CI-testable, the UI is not. That is separable from *what* the assertions
are, which is not knowable until the truth-file interface for unidentified
presence is settled. So it gets T2 + T4 with the assertions explicitly
deferred.

Recording it as T4-only was the tempting alternative, 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, so it stays in the denominator.

The gate now runs warning-free.

TRACES: JR-046

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 10:48:12 +02:00
Gitea Actions 12f076cf85 Update manifest.json for latest build (3d210b5) 2026-07-31 08:05:39 +00:00
dtourolleandClaude Opus 5 3d210b5bd3 Manifest fetch across the configured servers (JR-025 … JR-037)
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 40s
🧪 Test Plugin / test (push) Successful in 26s
Satisfies JRay-public-server UR-007. Servers are tried in configured order and
the first result clearing the configured tier wins; first-match rather than
best-match because querying every server for every item multiplies egress and
leaks the library to more parties, and the ordering already encodes which
source the admin prefers.

Every server is untrusted, including the pre-configured community one, so a
fetched manifest is re-validated against the same rules the server applies on
upload: envelope version refused if unknown, identifiers format-checked,
windows bounds-checked against the *local* file's runtime, belief bounded to
[0, 1], control and bidi characters refused in names. Responses are capped
while streaming rather than after buffering, since a hostile server can declare
any Content-Length it likes. HTTPS is required away from loopback. A failing
server is skipped with exponential backoff so one dead server cannot stall a
sweep.

The audio-tier offset is applied once, at store time, so stored truth is always
in the local file's own timebase and no read path needs offset awareness.
Windows are shifted, never reshaped — merging adjacent ones would answer "was a
face visible" rather than "was the actor present" (SR-002).

Also records why there is no `exact` tier, which was missing and led me to
re-add one. The file-hash tier is withdrawn on legal grounds: a TMDB id
discloses "some copy of this film", but an OpenSubtitles hash discloses "this
exact release", which turns a catalogue lookup into a release-identification
service and a server's database into a mapping from file fingerprints to the
instances holding them. The reason now lives on MatchTier and in SPEC.md §JR-036,
`TitleQuery` has no VideoHash property so there is nothing to send, and a test
asserts the enum has no Exact member — the spec had still listed `exact` as a
configurable tier, which is what made the removal look like an oversight.

42 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: JR-025, JR-027, JR-028, JR-029, JR-030, JR-031, JR-036, JR-037 | PR-005, PR-006
2026-07-31 10:03:49 +02:00
Gitea Actions 64f549ab35 Update manifest.json for latest build (2926740) 2026-07-31 07:19:53 +00:00
dtourolleandClaude Opus 5 2926740d03 JR-023: correct the missing-dependency logs, and test them
🏗️ Build Plugin / build (push) Successful in 29s
Latest Release / latest-release (push) Successful in 39s
🧪 Test Plugin / test (push) Successful in 26s
Both failure paths still told admins the overlay was "falling back to patching
index.html on disk". JR-021 deleted that fallback, so the message was false --
and it was false in the worst place, since an admin reads it precisely when
debugging a missing overlay and would go hunting for a patch that no longer
exists. They now name the install URL and say the overlay is disabled while
every other feature is unaffected. The not-found case is a Warning, not
Information: a headline feature being off should not sit among startup chatter.

UT-012..015 cover the branch. The File Transformation assembly is genuinely
absent from the test host, so TryRegister exercises its real not-found path
rather than a seam invented for the test. UT-015 pins that a null payload
returns empty rather than throwing -- this callback runs inside another
plugin's request path on every page served, so throwing would break the web
client itself, not just JRay's overlay.

Making those runnable needed the test project to reference Jellyfin.Controller
and Jellyfin.Model without the plugin's ExcludeAssets=runtime. My earlier claim
that DisableTransitiveFrameworkReferences alone sufficed was too narrow: it
drops the demand for the web *framework*, but ILogger and MediaBrowser.Common
live in the excluded assets, so anything past dependency-free logic failed at
run time with FileNotFoundException. Both settings are needed, and the register
now says so.

Second mutation check: downgrading the warning to Information fails UT-013 and
nothing else. Source restored and re-verified.

JR-023 reaches Done for the detection half. The config-page banner stays T4 --
verifiable only against a live server.

TRACES: UT-012, UT-013, UT-014, UT-015 | JR-023

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:18:40 +02:00
Gitea Actions 0fafa84158 Update manifest.json for latest build (cc973fd) 2026-07-31 07:11:48 +00:00
dtourolleandClaude Opus 5 cc973fd7c5 Make the test suite runnable without an ASP.NET Core runtime
🏗️ Build Plugin / build (push) Successful in 26s
Latest Release / latest-release (push) Successful in 37s
🧪 Test Plugin / test (push) Successful in 25s
The 11 tests built but could not execute: the plugin framework-references
Microsoft.AspNetCore.App through Jellyfin.Controller, that reference flows into
anything referencing the plugin, and the test host then demanded a web runtime
even for tests that touch no web type. This box has none at any version, and
Arch packages only 8 and 10 -- so "install the runtime" was neither available
here nor a clean answer.

DisableTransitiveFrameworkReferences drops the inherited reference. .NET
resolves assemblies lazily, so pure-logic types load without it. This is a
T1-tier decision rather than a workaround: requiring a web runtime to test
string and rule logic is incidental coupling. T2 -- controllers and
authorisation -- genuinely needs it, and belongs in a second project that keeps
the reference.

All 11 now pass. The suite was also checked to FAIL: removing the
newline-stripping from RemoveInjection fails UT-001 and nothing else, then the
source was restored and re-verified byte-identical. A suite that has only ever
passed is not evidence that it tests anything.

JR-016 and JR-022 therefore reach Done -- implemented and verified by tests
that execute. JR-023 stays In Progress: its detection branch has no test and
its config-page half is T4.

TRACES: JR-016, JR-022 | PR-003, PR-004

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-31 09:10:40 +02:00
Gitea Actions e16f903469 Update manifest.json for latest build (f4e8fb5) 2026-07-30 19:22:06 +00:00
dtourolleandClaude Opus 5 f4e8fb5dea Adopt the config-driven extractor
🏗️ Build Plugin / build (push) Successful in 28s
Latest Release / latest-release (push) Successful in 41s
🧪 Test Plugin / test (push) Successful in 25s
jray-project replaced the extractor's per-repo CLI flags with a traceability.toml
at each component root, so the invocation this repo documented -- --types,
--suffixes, --scan-roots -- no longer exists. Bumps the submodule to 17106f3 and
moves those settings into config.

The gate is now `extract_traces.py --root . --format coverage`, with nothing
per-repo on the command line to drift between a developer's shell and CI.

Two settings carry reasons worth keeping. Source roots are listed individually
rather than as "scripts", because the latter walks scripts/vendor and harvests
the AR-nnn examples in the extractor's own docstrings as orphan tags.
ci_executable_tiers omits T4: tiers are per-repo now, but T4 keeps the meaning
it has in scene-actor-extraction -- "no CI host can run this" -- so a tier
number reads the same across repos.

Drops the note about the tool expecting jRay to use UR/DR. Its example config
now names jRay: ["JR"], so the prefix is settled in all three repos.

Same numbers as before the change: 21 tags, 25/46, 0 orphans.

TRACES: JR-021 | PR-004

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:20:57 +02:00
Gitea Actions 2d043aeee3 Update manifest.json for latest build (5159692) 2026-07-30 17:13:49 +00:00
dtourolleandClaude Opus 5 5159692364 Test project: UT-001..011 for JR-016 and JR-022
🏗️ Build Plugin / build (push) Successful in 39s
Latest Release / latest-release (push) Successful in 38s
🧪 Test Plugin / test (push) Successful in 26s
The register defined 46 requirements and zero tests, so every Done in it rested
on inspection. This adds the T1 tier: an xUnit project in the solution,
covering the two units whose logic is pure enough to test without a Jellyfin
host.

JR-022 (UT-001..006) covers the cases where removal could reach too far --
another plugin's injection, and an unmarked look-alike script tag -- because
index.html is a file JRay shares. UT-001 pins the trailing newline to the tag,
without which every install cycle leaves another blank line behind.

JR-016 (UT-007..011) covers specificity resolution, including the prioritised
series inside an ignored genre that motivated the rule, and a Series rule
valued Guid.Empty, which would otherwise swallow every movie in the library.

RemoveInjection is reached through InternalsVisibleTo rather than being made
public: it is factored out for testability, not part of the surface.

The suite BUILDS but does not RUN here. Jellyfin.Controller framework-
references Microsoft.AspNetCore.App, so the test host demands it even for pure
logic, and this machine has no ASP.NET Core runtime at any version --
RollForward cannot substitute for a framework that is absent entirely. Fix is
to install aspnet-runtime, which the CI image needs for the same reason.

So every UT here is recorded as Written, not Passing, and no requirement is
promoted to Done on their strength. A test whose result nobody has seen is not
evidence.

TRACES: UT-001, UT-002, UT-003, UT-004, UT-005, UT-006 | JR-022
TRACES: UT-007, UT-008, UT-009, UT-010, UT-011 | JR-016

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:12:24 +02:00
Gitea Actions d786d56368 Update manifest.json for latest build (d9a38bb) 2026-07-30 17:03:45 +00:00
dtourolleandClaude Opus 5 d9a38bb7fb JR-021, JR-022, JR-023: File Transformation is a hard dependency
🏗️ Build Plugin / build (push) Successful in 59s
Latest Release / latest-release (push) Successful in 30s
🧪 Test Plugin / test (push) Successful in 22s
Deletes the on-disk index.html injection rather than leaving it switched off.
Plugin.cs had already stopped calling it, but an unreachable write path with a
live signature is the one a later refactor re-enables by accident, and it was
still the behaviour the README and the release changelog advertised.

Patching index.html on disk is destructive in ways a plugin cannot clean up
after: the patch outlives an uninstall, a web-client upgrade discards it
silently, and it races any other plugin touching the same file. It is also a
second code path, and the one nobody runs is the one that rots.

WebClientPatchService is now removal-only. The strip is factored out as
RemoveInjection so it is testable without a filesystem. Removal is the one
write JR-021 permits -- an earlier JRay did patch the file, and those users
must not be left with a stale injection pointing at endpoints that have since
changed. It keys on JRay's own marker, so it touches nothing another plugin
added.

JR-021 is a requirement to *not do* something, which no unit test can
demonstrate, so scripts/checks/no-index-injection.sh verifies it by absence.
The check was confirmed to fail on a reintroduced Apply() and on reintroduced
ReplaceLast injection -- a check that has only ever passed is not evidence.

Jellyfin has no plugin dependency mechanism, so nothing installs File
Transformation for the user and a log warning alone is one nobody reads.
GET /Plugins/JRay/Status/Dependencies reports whether the dependency is
satisfied, and the configuration page renders it with the repository URL and
what to do with it. Absent the plugin only the overlay is disabled; every
other feature works.

JR-022 and JR-023 stay In Progress rather than Done: neither has a test that
executes, and this repo has no test project yet.

TRACES: JR-021, JR-022, JR-023 | PR-004

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:58:12 +02:00
dtourolleandClaude Opus 5 3b24fe1b3c Requirements register, spec rewrite, and TRACES tags
jRay had no requirement IDs, so nothing in this repo could be traced to and
the CI gate had no denominator to read. The other two components had already
moved to registers; this brings the plugin level with them.

Adds docs/requirements.md with 46 permanent JR-nnn IDs, each carrying a parent
requirement, priority, status and verification tier, plus a per-requirement
verification plan. JR is flat rather than split by theme: the plugin is one
deployable with one audience, and JRay-public-server already ships UR/DR, so a
second repo using those prefixes would make UR-007 ambiguous across registers.

Rewrites SPEC.md as requirements prose with Current:/Gap: on every one. It had
drifted into a format-plus-API reference that documented schema_version 1 while
owning a format whose v2 shape was specified only in the other two repos, said
nothing about SR-002's scene-scoped semantics, and carried the manifest
exchange as a "planned" aside while its configuration classes were already
implemented. Plugin-side exchange obligations move here from the server's
spec, where they were an ownership inversion.

Adds JR-038..041 for PR-005, which had no software row in any repo -- it was
held structurally by SR-004 and GR-005 both being prohibitions, and a goal
preserved only by prohibitions is the kind that erodes unnoticed. jRay is the
component that actually opens a socket.

Tags 18 units with the requirements they satisfy. Tags name what the code
satisfies, so FileTransformationRegistration is not tagged JR-021: that
requirement is a prohibition and was still violated elsewhere when this was
written.

Vendors jray-project as a submodule for the system spec and shared gate.

TRACES: JR-001, JR-004, JR-005, JR-007, JR-008, JR-009, JR-010, JR-011
TRACES: JR-012, JR-013, JR-014, JR-015, JR-016, JR-017, JR-018, JR-019
TRACES: JR-020, JR-024, JR-025, JR-036, JR-038

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:57:48 +02:00
Gitea Actions 32f976e79f Update manifest.json for version 0.0.4 2026-07-04 19:47:05 +00:00
Gitea Actions 8e1679faed Update manifest.json for latest build (13471e2) 2026-07-04 19:44:35 +00:00
dtourolle 13471e21fb fix bug that causesd stale media (not on disk) to be suggested to workers
🏗️ Build Plugin / build (push) Successful in 20s
Latest Release / latest-release (push) Successful in 24s
🧪 Test Plugin / test (push) Successful in 19s
🚀 Release Plugin / build-and-release (push) Successful in 20s
2026-07-04 21:42:43 +02:00
Gitea Actions 2ffbd2f50e Update manifest.json for version 0.0.3 2026-07-04 19:11:39 +00:00
Gitea Actions edb93e1028 Update manifest.json for latest build (f6762fc) 2026-07-04 19:10:48 +00:00
dtourolle f6762fcf29 feat: prioritise and blacklist media and overview in setting page of progress in adding info
🏗️ Build Plugin / build (push) Successful in 1m16s
Latest Release / latest-release (push) Successful in 27s
🧪 Test Plugin / test (push) Successful in 20s
🚀 Release Plugin / build-and-release (push) Successful in 24s
2026-07-04 21:08:59 +02:00
dtourolle bb02c0f9b9 Enlarge actor overlay images and replace broken link with in-player popup
Clicking an actor card no longer navigates the SPA away from the player
(which lost playback position and often failed for person items). It now
opens a closeable in-player detail pop-up with a larger portrait and full
overview, dismissible via the close button, Back/Escape, the backdrop, or
by pressing play. On-screen card portraits enlarged from 80px to 120px.
2026-07-04 21:06:02 +02:00
dtourolle f11e5508ad update readme and spec for current release 2026-06-28 12:16:07 +02:00
Gitea Actions f5826084e0 Update manifest.json for version 0.0.2 2026-06-12 18:30:36 +00:00
Gitea Actions bea4d931c6 Update manifest.json for latest build (49c7077)
🚀 Release Plugin / build-and-release (push) Successful in 37s
2026-06-12 18:26:36 +00:00
dtourolle 49c7077e23 bump server version
🏗️ Build Plugin / build (push) Successful in 33s
Latest Release / latest-release (push) Successful in 26s
🧪 Test Plugin / test (push) Successful in 18s
2026-06-12 20:25:32 +02:00
Gitea Actions ff339b681b Update manifest.json for latest build (0e6b03b) 2026-06-12 18:17:26 +00:00
dtourolle 0e6b03be15 fancier overlay
🏗️ Build Plugin / build (push) Successful in 21s
Latest Release / latest-release (push) Successful in 23s
🧪 Test Plugin / test (push) Successful in 18s
2026-06-12 20:16:39 +02:00
Gitea Actions 772babe9bc Update manifest.json for version 0.0.1 2026-06-12 18:11:18 +00:00
Gitea Actions f8365a5b58 Update manifest.json for latest build (4b3b64b) 2026-06-12 18:10:35 +00:00
dtourolle 4b3b64bd7e json copying
🏗️ Build Plugin / build (push) Successful in 22s
Latest Release / latest-release (push) Successful in 28s
🧪 Test Plugin / test (push) Successful in 17s
🚀 Release Plugin / build-and-release (push) Successful in 27s
debugging overlay
2026-06-12 20:09:36 +02:00
Gitea Actions abad55788d Update manifest.json for version 0.0.0 2026-06-12 17:05:10 +00:00
Gitea Actions dea172d990 Update manifest.json for latest build (51a7365) 2026-06-12 17:04:27 +00:00
96 changed files with 10950 additions and 593 deletions
+10
View File
@@ -42,6 +42,16 @@ jobs:
working-directory: test-${{ github.run_id }}
run: dotnet build Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-self-contained /m:1
# The workflow is named "Test Plugin" and until now only compiled one. A
# test that is built but never run is not evidence, and JR-043 is the case
# that makes it matter: the point of a golden vector shared with the
# extraction repo is that CI fails when the two implementations drift.
# T1 needs no ASP.NET runtime and no FFmpeg — the audio golden check
# regenerates its own fixture PCM.
- name: Run tests
working-directory: test-${{ github.run_id }}
run: dotnet test Jellyfin.Plugin.JRay.sln --configuration Debug --no-restore --no-build
- name: Cleanup
if: always()
run: rm -rf test-${{ github.run_id }}
+9
View File
@@ -3,3 +3,12 @@ obj/
.vs/
.idea/
artifacts
# Python artefacts from the traceability tooling
__pycache__/
*.pyc
# Generated traceability output — regenerate with the gate, never hand-edit.
# `docs/traceability.md` is committed, as in the other two components; the JSON
# report is not, since nothing reads it back.
traces-report.json
+3
View File
@@ -0,0 +1,3 @@
[submodule "scripts/vendor/jray-project"]
path = scripts/vendor/jray-project
url = git@gitea.tourolle.paris:dtourolle/jray-project.git
@@ -0,0 +1,270 @@
using System;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-044 (media shorter than 120 s emits no signature and takes no sync offset)
/// and JR-045 (the `v1:` prefix is emitted *and honoured*).
///
/// Both requirements have a producer half and a consumer half. The producer
/// halves live with <see cref="AudioSignature"/> and were closed by
/// UT-038 … UT-044; these are the consumer halves, which needed a reader —
/// <see cref="AudioSignatureMatcher"/> — before they could be closed at all.
///
/// The distinction that matters throughout: **refusing is a correct answer, and
/// a silently wrong one is not.** An unreadable or out-of-version signature must
/// drop the item to the runtime tier, never score as if it were understood.
///
/// TRACES: UT-045, UT-046, UT-047, UT-048, UT-049, UT-050, UT-051, UT-052 | JR-044, JR-045
/// </summary>
public class AudioSignatureMatcherTests
{
private static readonly string FixtureDir =
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
private static readonly Lazy<string> GoldenSignature = new(() =>
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json")))
.RootElement.GetProperty("signature").GetString()!);
private static readonly Lazy<byte[]> GoldenFrames = new(() =>
AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value)!);
// A feature-length runtime, so the window-anchor term is exercised at a
// realistic scale rather than at the 120 s boundary.
private const double FeatureRuntime = 7200.0;
// UT-045 — JR-044, the producer boundary.
[Fact]
public async Task ShortMedia_YieldsNoSignature_AndExactly120sDoes()
{
// Decisive because it is the *same file* either side of the boundary:
// only the runtime differs, so a null cannot be blamed on the decode.
// The fixture is exactly 120.000 s, which is the boundary itself.
var fixture = Path.Combine(FixtureDir, "jray_audio_v1_tone.flac");
// Below the window: refused before the encoder is ever consulted, which
// is what the deliberately invalid path proves.
Assert.Null(await AudioSignatureService.ComputeWithEncoderAsync(
"/nonexistent/ffmpeg",
fixture,
AudioSignature.WindowSec - 0.001,
NullLogger.Instance,
CancellationToken.None).ConfigureAwait(true));
var ffmpeg = FindFfmpeg();
if (ffmpeg is null)
{
return;
}
Assert.Null(await AudioSignatureService.ComputeWithEncoderAsync(
ffmpeg,
fixture,
AudioSignature.WindowSec - 0.001,
NullLogger.Instance,
CancellationToken.None).ConfigureAwait(true));
// Exactly at the boundary the window fits, so a signature is emitted.
// Both producers must agree here or they diverge on precisely the short
// items most likely to be misidentified.
Assert.Equal(
GoldenSignature.Value,
await AudioSignatureService.ComputeWithEncoderAsync(
ffmpeg,
fixture,
AudioSignature.WindowSec,
NullLogger.Instance,
CancellationToken.None).ConfigureAwait(true));
}
// UT-046 — JR-044, the consumer half.
[Fact]
public void ShortMedia_TakesNoOffset_EvenWithTwoValidSignatures()
{
var signature = GoldenSignature.Value;
// Two identical, perfectly valid signatures — the strongest possible
// match — still yield nothing when either side is under the window. The
// rule is checked on the runtime, not inferred from a missing string,
// because the runtime is what both producers test.
Assert.Null(AudioSignatureMatcher.Compare(
signature, signature, AudioSignature.WindowSec - 0.001, FeatureRuntime));
Assert.Null(AudioSignatureMatcher.Compare(
signature, signature, FeatureRuntime, AudioSignature.WindowSec - 0.001));
// At exactly the boundary it matches, so the refusal above is the
// threshold and not a blanket refusal.
Assert.NotNull(AudioSignatureMatcher.Compare(
signature, signature, AudioSignature.WindowSec, AudioSignature.WindowSec));
}
// UT-047 — JR-045, the requirement's whole point.
[Fact]
public void UnknownVersionPrefix_IsRefused_NotParsed()
{
// A v2 signature from a future producer, whose payload is byte-identical
// to a valid v1 one. Parsing it as v1 would yield a confident, plausible,
// wrong score; refusing drops the item to the runtime tier, which is
// correct. This is the entire reason the prefix is separate from
// schema_version.
var payload = GoldenSignature.Value[AudioSignature.VersionPrefix.Length..];
Assert.Null(AudioSignatureMatcher.TryParseFrames("v2:" + payload));
Assert.Null(AudioSignatureMatcher.TryParseFrames("v10:" + payload));
Assert.Null(AudioSignatureMatcher.TryParseFrames(payload)); // no prefix at all
Assert.Null(AudioSignatureMatcher.TryParseFrames("V1:" + payload)); // case is not cosmetic
// And it must be refused by the matcher too, not merely by the parser —
// a v2 signature produces no match and therefore no offset.
Assert.Null(AudioSignatureMatcher.Compare(
GoldenSignature.Value, "v2:" + payload, FeatureRuntime, FeatureRuntime));
Assert.Null(AudioSignatureMatcher.Compare(
"v2:" + payload, GoldenSignature.Value, FeatureRuntime, FeatureRuntime));
}
// UT-048 — JR-045, the accepting side.
[Fact]
public void V1Signature_ParsesToExactlyTheProducedFrames()
{
// The reader is the inverse of the producer, checked against the golden
// vector rather than against the producer's own output, so the two are
// pinned to the fixture and not merely to each other.
var frames = AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value);
Assert.NotNull(frames);
Assert.Equal(AudioSignature.ExpectedFrames, frames!.Length);
Assert.Equal(GoldenSignature.Value, AudioSignature.VersionPrefix + Convert.ToBase64String(frames));
}
// UT-049 — JR-045, structural refusal.
[Fact]
public void MalformedSignatures_AreRefused_WithoutThrowing()
{
Assert.Null(AudioSignatureMatcher.TryParseFrames(null));
Assert.Null(AudioSignatureMatcher.TryParseFrames(string.Empty));
Assert.Null(AudioSignatureMatcher.TryParseFrames("v1:")); // empty payload
Assert.Null(AudioSignatureMatcher.TryParseFrames("v1:not!base64!"));
// Bit 7 is reserved by the packing — a byte with it set is not a frame.
// The server refuses this on upload; the client must not accept what the
// server would have rejected.
Assert.Null(AudioSignatureMatcher.TryParseFrames(
AudioSignature.VersionPrefix + Convert.ToBase64String(new byte[] { 0x04, 0x80, 0x08 })));
}
// UT-050 — the aligned case.
[Fact]
public void IdenticalSignatures_ScorePerfectly_AtZeroOffset()
{
var match = AudioSignatureMatcher.Compare(
GoldenSignature.Value, GoldenSignature.Value, FeatureRuntime, FeatureRuntime);
Assert.NotNull(match);
Assert.Equal(1.0, match!.Value.Score);
Assert.Equal(0, match.Value.OffsetFrames);
Assert.Equal(0.0, match.Value.OffsetSec);
Assert.Equal(MatchTier.Audio, match.Value.Tier);
}
// UT-051 — the case the feature exists for.
[Fact]
public void AShiftedRelease_RecoversTheOffset_RatherThanFailingToMatch()
{
// A release trimmed differently from the one the manifest was built on:
// the same cut, sampled at a different point. Before offset recovery this
// failed the runtime tier outright; the recovered shift is what makes one
// stored manifest serve every trim.
const int Shift = 100;
const int Span = 1000;
const int RemoteStart = 144;
var source = GoldenFrames.Value;
var remote = Signature(source, RemoteStart, Span);
var local = Signature(source, RemoteStart - Shift, Span);
var match = AudioSignatureMatcher.Compare(local, remote, FeatureRuntime, FeatureRuntime);
Assert.NotNull(match);
Assert.Equal(1.0, match!.Value.Score);
Assert.Equal(Shift, match.Value.OffsetFrames);
Assert.Equal(MatchTier.Audio, match.Value.Tier);
// Equal runtimes, so the window-anchor term vanishes and the offset is
// purely the recovered slide.
Assert.Equal(Shift * AudioSignatureMatcher.FrameSeconds, match.Value.OffsetSec, 9);
}
// UT-052 — the rejecting case, and the anchor term.
[Fact]
public void UnrelatedContent_DoesNotMatch_AndRuntimeSkewShiftsTheOffset()
{
// Two independent band sequences agree about 1 frame in 32, far below the
// loose floor. A matcher that returned its argmax regardless would hand
// back a confident alignment for unrelated films.
Assert.Null(AudioSignatureMatcher.Compare(
GoldenSignature.Value, PseudoRandomSignature(1288, seed: 12345), FeatureRuntime, FeatureRuntime));
// Both windows are centred on their own file's midpoint, so when the
// runtimes differ the windows start at different absolute times and that
// difference is part of the offset. Without this term the offset would be
// wrong by half the runtime difference on every shifted release.
var skew = 40.0;
var match = AudioSignatureMatcher.Compare(
GoldenSignature.Value,
GoldenSignature.Value,
FeatureRuntime + skew,
FeatureRuntime);
Assert.NotNull(match);
Assert.Equal(0, match!.Value.OffsetFrames);
Assert.Equal(skew / 2.0, match.Value.OffsetSec, 9);
}
private static string Signature(byte[] source, int start, int count)
=> AudioSignature.VersionPrefix + Convert.ToBase64String(source, start, count);
private static string PseudoRandomSignature(int frames, int seed)
{
var bytes = new byte[frames];
var state = (uint)seed;
for (var i = 0; i < frames; i++)
{
// Deterministic LCG — a fixed sequence, so a failure here is
// reproducible rather than flaky.
state = (state * 1664525u) + 1013904223u;
bytes[i] = (byte)((((state >> 16) % AudioSignature.NumBands) << 2) | ((state >> 8) & 0x03));
}
return AudioSignature.VersionPrefix + Convert.ToBase64String(bytes);
}
private static string? FindFfmpeg()
{
var configured = Environment.GetEnvironmentVariable("JRAY_TEST_FFMPEG");
if (!string.IsNullOrEmpty(configured))
{
return File.Exists(configured) ? configured : null;
}
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(dir, name);
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
}
@@ -0,0 +1,366 @@
using System;
using System.Buffers.Binary;
using System.Globalization;
using System.IO;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Services;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-042 (the signature is computed exactly per server specification §3) and
/// JR-043 (the golden-vector fixture shared with the extraction repo).
///
/// The headline claim is cross-repo: the C++ pipeline and this plugin are two
/// independent implementations of one fingerprint, and two fingerprints that
/// differ in any parameter simply do not match. That claim is only worth
/// anything if it is checked, so `fixtures/audio/` holds the same three files
/// the extraction repo holds — byte-identical — and these tests assert against
/// the values recorded in them, never against each other.
///
/// The binding check needs no FFmpeg. `make_fixture.py` generates the fixture
/// media from plain arithmetic, so UT-038 regenerates that PCM here and proves
/// it is byte-identical to what the pipeline decoded, using the checksums the
/// fixture records. Everything after that is pure DSP, which is what lets this
/// run on any CI host — the extraction repo's counterpart (UT-101) drives the
/// same vector through libavcodec, and both must land on the same string.
///
/// TRACES: UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044 | JR-042, JR-043
/// </summary>
public class AudioSignatureTests
{
private static readonly string FixtureDir =
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
private static readonly Lazy<JsonDocument> GoldenDoc = new(() =>
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json"))));
private static readonly Lazy<short[]> FixturePcm = new(GenerateFixturePcm);
private static readonly Lazy<float[]> FixtureSamples = new(() =>
{
var pcm = FixturePcm.Value;
var samples = new float[pcm.Length];
for (var i = 0; i < pcm.Length; i++)
{
// FFmpeg's native s16 -> flt conversion. 1/32768 is a power of two,
// so this is exact rather than merely close.
samples[i] = pcm[i] / 32768f;
}
return samples;
});
private static JsonElement Golden => GoldenDoc.Value.RootElement;
private static string GoldenSignature => Golden.GetProperty("signature").GetString()!;
// UT-038
[Fact]
public void RegeneratedFixturePcm_MatchesTheRecordedDecodedWindow()
{
// Checked before the signature, and separately from it, so a mismatch
// is diagnosable: if this passes and UT-039 fails, the DSP diverged; if
// this fails, the input did, and the signature comparison would only
// have told you "different" without saying where.
var pcm = FixturePcm.Value;
var decoded = Golden.GetProperty("decoded_window");
Assert.Equal(decoded.GetProperty("samples").GetInt32(), pcm.Length);
var s16 = new byte[pcm.Length * sizeof(short)];
for (var i = 0; i < pcm.Length; i++)
{
BinaryPrimitives.WriteInt16LittleEndian(s16.AsSpan(i * sizeof(short)), pcm[i]);
}
Assert.Equal(Hex64(decoded.GetProperty("s16le_fnv1a64").GetString()!), Fnv1a64(s16));
var f32 = new byte[FixtureSamples.Value.Length * sizeof(float)];
for (var i = 0; i < FixtureSamples.Value.Length; i++)
{
BinaryPrimitives.WriteSingleLittleEndian(f32.AsSpan(i * sizeof(float)), FixtureSamples.Value[i]);
}
Assert.Equal(Hex64(decoded.GetProperty("f32le_fnv1a64").GetString()!), Fnv1a64(f32));
}
// UT-039
[Fact]
public void Signature_OfTheGoldenFixture_MatchesTheRecordedValueExactly()
{
// The cross-repo check. Not "close", not "matches to within a tier" —
// the same string the C++ producer emits for the same audio.
Assert.Equal(GoldenSignature, AudioSignature.FromMonoSamples(FixtureSamples.Value));
}
// UT-040
[Fact]
public void BandTable_MatchesTheRecordedOne_AndTilesTheRangeExactly()
{
// The band-to-FFT-bin table is the part of the construction most likely
// to drift between two implementations — an off-by-one in a ceiling, a
// half-open range read as closed — so it is pinned independently of the
// signature it produces.
var table = AudioSignature.BandFftBins();
var want = Golden.GetProperty("band_fft_bins");
Assert.Equal(want.GetArrayLength(), table.Count);
for (var b = 0; b < table.Count; b++)
{
Assert.Equal(want[b][0].GetInt32(), table[b].Low);
Assert.Equal(want[b][1].GetInt32(), table[b].High);
Assert.True(table[b].High > table[b].Low, $"band {b} is empty");
if (b > 0)
{
// Contiguous, so the frame energy really is the sum of the band
// sums — no gap, no bin counted twice.
Assert.Equal(table[b - 1].High, table[b].Low);
}
}
}
// UT-041
[Fact]
public void Signature_IsWellFormed_PrefixFrameCountAndStructuralBytes()
{
var signature = AudioSignature.FromMonoSamples(FixtureSamples.Value);
Assert.NotNull(signature);
// JR-045 — the signature carries its own version, separate from
// schema_version, so a future DSP change is detectable rather than
// silently producing signatures that no longer match.
Assert.StartsWith(AudioSignature.VersionPrefix, signature, StringComparison.Ordinal);
var bytes = Convert.FromBase64String(signature[AudioSignature.VersionPrefix.Length..]);
Assert.Equal(Golden.GetProperty("frame_count").GetInt32(), bytes.Length);
Assert.Equal(AudioSignature.ExpectedFrames, bytes.Length);
// The server validates this structure on upload: each byte is a 5-bit
// band index plus a 2-bit energy class, so bit 7 is always clear and an
// arbitrary byte is not a valid signature. That is what keeps the field
// from being usable as a payload channel.
var bandsSeen = new bool[AudioSignature.NumBands];
var classesSeen = new bool[4];
foreach (var b in bytes)
{
Assert.Equal(0, b & 0x80);
bandsSeen[(b >> 2) & 0x1F] = true;
classesSeen[b & 0x03] = true;
}
// The fixture is built to exercise the whole output alphabet. If it ever
// stops doing so, the golden vector has become a weaker check than it
// looks — so that property is asserted rather than assumed.
Assert.All(bandsSeen, Assert.True);
Assert.All(classesSeen, Assert.True);
}
// UT-042
[Fact]
public void PackFrames_UsesWholeFramesOnly()
{
Assert.Empty(AudioSignature.PackFrames(new float[AudioSignature.FrameSize - 1]));
Assert.Single(AudioSignature.PackFrames(new float[AudioSignature.FrameSize]));
Assert.Single(AudioSignature.PackFrames(
new float[AudioSignature.FrameSize + AudioSignature.HopSize - 1]));
Assert.Equal(2, AudioSignature.PackFrames(
new float[AudioSignature.FrameSize + AudioSignature.HopSize].AsSpan()).Length);
// A partial frame is not a signature: below one frame there is nothing
// to emit, and emitting a padded frame would be a different fingerprint
// from the pipeline's.
Assert.Null(AudioSignature.FromMonoSamples(new float[AudioSignature.FrameSize - 1]));
// The full window is 1288 frames — asserted as a constant rather than by
// running the DSP over 1.3M zeros, which is the same claim for free.
Assert.Equal(1323000, AudioSignature.WindowSamples);
Assert.Equal(
1 + ((AudioSignature.WindowSamples - AudioSignature.FrameSize) / AudioSignature.HopSize),
AudioSignature.ExpectedFrames);
}
// UT-043
[Fact]
public async Task Decode_ThroughFfmpeg_ReproducesTheGoldenSignature()
{
// The one test that exercises the real decode — the command line, the
// stream selection, the downmix and resample — rather than the DSP
// alone. It needs an FFmpeg binary, which the plugin gets from Jellyfin
// at run time and which a bare CI container may not have; UT-038 and
// UT-039 are what make the cross-repo claim binding without one.
var ffmpeg = FindFfmpeg();
if (ffmpeg is null)
{
return;
}
var signature = await AudioSignatureService.ComputeWithEncoderAsync(
ffmpeg,
Path.Combine(FixtureDir, "jray_audio_v1_tone.flac"),
AudioSignature.WindowSec,
NullLogger.Instance,
CancellationToken.None).ConfigureAwait(true);
Assert.Equal(GoldenSignature, signature);
}
// UT-044
[Fact]
public async Task Decode_TakesTheWindowFromTheCentre_NotTheHead()
{
// Sampling from the centre is the whole reason the construction avoids
// the head and tail — logos and cold opens at one end, credits at the
// other — so it needs its own check. Nothing else here pins the seek: a
// head-anchored window passes every other test in this file.
var ffmpeg = FindFfmpeg();
if (ffmpeg is null)
{
return;
}
var pad = 90 * AudioSignature.SampleRate;
var padded = new short[(pad * 2) + FixturePcm.Value.Length];
FixturePcm.Value.CopyTo(padded, pad);
var wav = Path.Combine(Path.GetTempPath(), $"jray_audio_centre_{Environment.ProcessId}.wav");
try
{
WriteWav(wav, padded);
var signature = await AudioSignatureService.ComputeWithEncoderAsync(
ffmpeg,
wav,
padded.Length / (double)AudioSignature.SampleRate,
NullLogger.Instance,
CancellationToken.None).ConfigureAwait(true);
Assert.Equal(GoldenSignature, signature);
}
finally
{
File.Delete(wav);
}
}
/// <summary>
/// Regenerates the fixture media's PCM, as <c>make_fixture.py</c> defines
/// it: 120 s of tones stepping through all 32 log-bands, amplitudes walking
/// a golden-ratio sequence so all four energy classes appear, over a quiet
/// constant 777 Hz bed so no frame is degenerate.
/// </summary>
/// <remarks>
/// A port, not a re-derivation — the point is that a repo can rebuild the
/// input from scratch and check the result against the recorded checksums,
/// which is what UT-038 does. The closest sample to a quantisation boundary
/// sits 3.7e-7 away from one, so the result does not depend on which libm
/// rounds the sine.
/// </remarks>
private static short[] GenerateFixturePcm()
{
const int Segment = 32768;
const int BandStride = 7;
const double AmpLogMin = -1.55;
const double AmpLogSpan = 1.53;
const double PhiFrac = 0.6180339887498949;
const double BackgroundHz = 777.0;
const double BackgroundAmp = 0.004;
var n = (int)Math.Round(AudioSignature.SampleRate * AudioSignature.WindowSec);
var samples = new short[n];
var twoPi = 2.0 * Math.PI;
var phase = 0.0;
for (var start = 0; start < n; start += Segment)
{
var s = start / Segment;
var end = Math.Min(n, start + Segment);
var band = (s * BandStride) % AudioSignature.NumBands;
var freq = AudioSignature.BandLoHz * Math.Pow(
AudioSignature.BandHiHz / AudioSignature.BandLoHz,
(band + 0.5) / AudioSignature.NumBands);
var amp = Math.Pow(10.0, AmpLogMin + (AmpLogSpan * ((s * PhiFrac) % 1.0)));
var step = twoPi * freq / AudioSignature.SampleRate;
for (var k = 0; k < end - start; k++)
{
var i = start + k;
var x = amp * Math.Sin(phase + (step * k));
x += BackgroundAmp * Math.Sin(twoPi * BackgroundHz * i / AudioSignature.SampleRate);
x = Math.Clamp(x, -1.0, 1.0);
samples[i] = (short)Math.Floor((x * 32767.0) + 0.5);
}
phase = (phase + (step * (end - start))) % twoPi;
}
return samples;
}
private static void WriteWav(string path, short[] samples)
{
var dataBytes = samples.Length * sizeof(short);
using var stream = File.Create(path);
using var writer = new BinaryWriter(stream);
writer.Write("RIFF"u8);
writer.Write(36 + dataBytes);
writer.Write("WAVE"u8);
writer.Write("fmt "u8);
writer.Write(16); // PCM header size
writer.Write((short)1); // PCM
writer.Write((short)1); // mono
writer.Write(AudioSignature.SampleRate);
writer.Write(AudioSignature.SampleRate * sizeof(short));
writer.Write((short)sizeof(short)); // block align
writer.Write((short)16); // bits per sample
writer.Write("data"u8);
writer.Write(dataBytes);
foreach (var sample in samples)
{
writer.Write(sample);
}
}
private static string? FindFfmpeg()
{
var configured = Environment.GetEnvironmentVariable("JRAY_TEST_FFMPEG");
if (!string.IsNullOrEmpty(configured))
{
return File.Exists(configured) ? configured : null;
}
var name = OperatingSystem.IsWindows() ? "ffmpeg.exe" : "ffmpeg";
foreach (var dir in (Environment.GetEnvironmentVariable("PATH") ?? string.Empty)
.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries))
{
var candidate = Path.Combine(dir, name);
if (File.Exists(candidate))
{
return candidate;
}
}
return null;
}
private static ulong Fnv1a64(ReadOnlySpan<byte> data)
{
var hash = 0xcbf29ce484222325UL;
foreach (var b in data)
{
hash ^= b;
hash *= 0x100000001b3UL;
}
return hash;
}
private static ulong Hex64(string value)
{
var digits = value.StartsWith("0x", StringComparison.OrdinalIgnoreCase) ? value[2..] : value;
return ulong.Parse(digits, NumberStyles.HexNumber, CultureInfo.InvariantCulture);
}
}
@@ -0,0 +1,96 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Microsoft.Extensions.Logging;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-023 — absent the File Transformation plugin, disable *only* the overlay
/// and say so. There is no on-disk fallback (JR-021), so the failure path is
/// the one an admin will actually meet, and it has to be legible: silently
/// serving no overlay is indistinguishable from a broken install.
///
/// The File Transformation assembly is not loaded in the test host, so
/// TryRegister exercises its not-found branch for real rather than through a
/// seam invented for the test.
///
/// TRACES: UT-012, UT-013, UT-014, UT-015 | JR-023
/// </summary>
public class FileTransformationRegistrationTests
{
// UT-012
[Fact]
public void TryRegister_WithPluginAbsent_ReturnsFalse()
{
Assert.False(FileTransformationRegistration.TryRegister(new CapturingLogger()));
}
// UT-013
[Fact]
public void TryRegister_WithPluginAbsent_WarnsNamingThePluginAndHowToInstallIt()
{
var logger = new CapturingLogger();
FileTransformationRegistration.TryRegister(logger);
var entry = Assert.Single(logger.Entries);
// Warning, not Information: the overlay is a headline feature and it is
// off. Logging this at Information buries it among startup chatter.
Assert.Equal(LogLevel.Warning, entry.Level);
// The message has to carry the install URL, and must not promise the
// on-disk fallback that JR-021 removed.
Assert.Contains(FileTransformationRegistration.ManifestUrl, entry.Message, StringComparison.Ordinal);
Assert.DoesNotContain("falling back", entry.Message, StringComparison.OrdinalIgnoreCase);
}
// UT-014
[Fact]
public void TransformIndexHtml_WithOverlayDisabled_ReturnsContentsUnchanged()
{
// No Plugin instance exists in the test host, so OverlayEnabled is
// false — the same branch taken when an admin switches the overlay off.
var html = "<html><body><div>x</div>\n</body></html>";
var result = FileTransformationRegistration.TransformIndexHtml(
new TransformationPayload { Contents = html });
Assert.Equal(html, result);
}
// UT-015
[Fact]
public void TransformIndexHtml_WithNullContents_ReturnsEmptyRatherThanThrowing()
{
// This callback is invoked by another plugin's code on every page
// served. Throwing here would break the web client itself, not just
// JRay's overlay.
var result = FileTransformationRegistration.TransformIndexHtml(new TransformationPayload());
Assert.Equal(string.Empty, result);
}
private sealed class CapturingLogger : ILogger
{
public List<(LogLevel Level, string Message)> Entries { get; } = new();
public IDisposable? BeginScope<TState>(TState state)
where TState : notnull => null;
public bool IsEnabled(LogLevel logLevel) => true;
public void Log<TState>(
LogLevel logLevel,
EventId eventId,
TState state,
Exception? exception,
Func<TState, Exception?, string> formatter)
{
Entries.Add((logLevel, formatter(state, exception)));
}
}
}
@@ -0,0 +1,68 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Nullable>enable</Nullable>
<IsPackable>false</IsPackable>
<!--
The plugin project treats warnings as errors and runs StyleCop. Tests do
not inherit that: their naming conventions differ deliberately (Method_
Condition_Expectation reads as documentation, and trips SA1300-family
rules), and a style failure in a test is not a defect in the thing under
test.
-->
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
<GenerateDocumentationFile>false</GenerateDocumentationFile>
<!--
The plugin targets net9.0 to match Jellyfin's ABI, but a machine that can
build it need not have the 9.0 *runtime* installed. Roll the test host
forward to whatever major is present so the suite runs on a developer box
and on CI without pinning either to a runtime that is not the plugin's.
-->
<RollForward>LatestMajor</RollForward>
<!--
The plugin framework-references Microsoft.AspNetCore.App through
Jellyfin.Controller, and that reference flows into anything referencing
the plugin. The T1 tier tests pure logic that touches no web type, so
inheriting the web framework would make the suite unrunnable on any box
without the ASP.NET Core runtime for no benefit. .NET resolves assemblies
lazily, so types that never touch ASP.NET load fine without it.
T2 (controllers, authorisation) genuinely needs that runtime. When those
tests arrive they belong in a second project that keeps this reference.
-->
<DisableTransitiveFrameworkReferences>true</DisableTransitiveFrameworkReferences>
</PropertyGroup>
<ItemGroup>
<!--
The plugin sets ExcludeAssets=runtime on these: at run time the Jellyfin
server supplies them, so shipping copies in the plugin would risk loading
a second, different MediaBrowser.Common. The test host is not the server,
so it has to bring its own — hence the same packages without that
exclusion, and only here.
-->
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" />
<PackageReference Include="Jellyfin.Model" Version="10.11.5" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.11.1" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj" />
</ItemGroup>
<ItemGroup>
<!--
The audio-signature golden vector, shared verbatim with the extraction
repo (JR-043): the same three files, byte for byte, in both repos. Two
independent implementations of one fingerprint are only useful if they
agree exactly, and this is what makes that a checked claim.
-->
<Content Include="fixtures\**\*">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</Content>
</ItemGroup>
</Project>
@@ -0,0 +1,194 @@
using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging.Abstractions;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-010 — three sources now deliver truth data (sidecar, pushed, fetched) and
/// they are not interchangeable. A locally computed sidecar and a
/// <c>loose</c>-tier manifest from a third-party server make claims of very
/// different strength about the same item, and the truth file itself records
/// nothing about how it arrived.
///
/// TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010
/// </summary>
public class ManagedTruthStoreTests : IDisposable
{
private readonly string _root;
private readonly ManagedTruthStore _store;
public ManagedTruthStoreTests()
{
_root = Path.Combine(Path.GetTempPath(), "jray-tests-" + Guid.NewGuid().ToString("N"));
Directory.CreateDirectory(_root);
_store = new ManagedTruthStore(new FakePaths(_root), NullLogger<ManagedTruthStore>.Instance);
}
public void Dispose()
{
GC.SuppressFinalize(this);
if (Directory.Exists(_root))
{
Directory.Delete(_root, recursive: true);
}
}
private static TruthFile Truth()
{
var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion, Movie = "/m.mkv" };
var actor = new TruthActor { Name = "A", TmdbId = "884" };
actor.Scenes.Add(new TruthScene { Start = 1.0, End = 2.0 });
truth.Actors.Add(actor);
return truth;
}
// UT-024
[Fact]
public async Task SaveAsync_ThenLoadProvenance_RoundTripsAFetchedClaim()
{
var id = Guid.NewGuid();
var recorded = new DateTime(2026, 7, 31, 12, 0, 0, DateTimeKind.Utc);
await _store.SaveAsync(
id,
Truth(),
new TruthProvenance
{
Source = TruthSource.Fetched,
ServerUrl = "https://jray.example",
MatchTier = MatchTier.Loose,
OffsetSec = -12.5,
Caveat = "loose match",
RecordedAt = recorded,
},
CancellationToken.None);
var loaded = _store.LoadProvenance(id);
Assert.NotNull(loaded);
Assert.Equal(TruthSource.Fetched, loaded!.Source);
Assert.Equal("https://jray.example", loaded.ServerUrl);
Assert.Equal(MatchTier.Loose, loaded.MatchTier);
// The offset is unrecoverable once applied: the stored windows look
// native, and nothing else would say they had been shifted.
Assert.Equal(-12.5, loaded.OffsetSec);
Assert.Equal("loose match", loaded.Caveat);
Assert.Equal(recorded, loaded.RecordedAt);
}
// UT-025
[Fact]
public async Task SaveAsync_LocalPush_RecordsNoServerOrTier()
{
var id = Guid.NewGuid();
await _store.SaveAsync(
id,
Truth(),
TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow),
CancellationToken.None);
var loaded = _store.LoadProvenance(id)!;
Assert.Equal(TruthSource.Pushed, loaded.Source);
Assert.Equal(string.Empty, loaded.ServerUrl);
// A push is about *this* file, so there is no cut to match. A tier here
// would be a fabricated claim.
Assert.Null(loaded.MatchTier);
}
// UT-026
[Fact]
public async Task SaveAsync_DoesNotWriteProvenanceIntoTheTruthFile()
{
var id = Guid.NewGuid();
await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None);
var truthJson = await File.ReadAllTextAsync(
Path.Combine(_root, "plugins", "configurations", "JRay", "truth", id.ToString("D") + ".json"));
// JR-004: the bytes served back are the bytes the producer wrote.
Assert.DoesNotContain("source", truthJson, StringComparison.OrdinalIgnoreCase);
Assert.DoesNotContain("match_tier", truthJson, StringComparison.OrdinalIgnoreCase);
}
// UT-027
[Fact]
public async Task Delete_RemovesProvenanceToo()
{
var id = Guid.NewGuid();
await _store.SaveAsync(id, Truth(), TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow), CancellationToken.None);
Assert.True(_store.Delete(id));
// A stale provenance record outliving its truth file would describe data
// the next fetch has already replaced.
Assert.Null(_store.LoadProvenance(id));
Assert.False(_store.Exists(id));
}
// UT-028
[Fact]
public void LoadProvenance_ForUnknownItem_ReturnsNull()
{
Assert.Null(_store.LoadProvenance(Guid.NewGuid()));
}
private sealed class FakePaths : IApplicationPaths
{
public FakePaths(string root)
{
ProgramDataPath = root;
PluginsPath = Path.Combine(root, "plugins");
PluginConfigurationsPath = Path.Combine(root, "plugins", "configurations");
}
public string ProgramDataPath { get; }
public string WebPath => Path.Combine(ProgramDataPath, "web");
public string ProgramSystemPath => ProgramDataPath;
public string DataPath => ProgramDataPath;
public string ImageCachePath => ProgramDataPath;
public string PluginsPath { get; }
public string PluginConfigurationsPath { get; }
public string LogDirectoryPath => ProgramDataPath;
public string ConfigurationDirectoryPath => ProgramDataPath;
public string SystemConfigurationFilePath => Path.Combine(ProgramDataPath, "system.xml");
public string CachePath { get; set; } = string.Empty;
public string TempDirectory => Path.Combine(ProgramDataPath, "temp");
public string TrickplayPath => Path.Combine(ProgramDataPath, "trickplay");
public string VirtualDataPath => ProgramDataPath;
public string BackupPath => Path.Combine(ProgramDataPath, "backup");
public void MakeSanityCheckOrThrow()
{
}
public void CreateAndCheckMarker(string path, string markerName, bool recursive = false)
{
}
}
}
@@ -0,0 +1,214 @@
using System;
using System.IO;
using System.Text.Json;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-047 — a fetched manifest is aligned against the local file before its
/// windows are stored, and how that was decided is recorded.
///
/// The claim under test is that **the local alignment supersedes the server's
/// offset**. The server has never seen this file; its offset can only be a
/// runtime-difference inference, while a local alignment compares the manifest's
/// own signature against the media the windows will be drawn over.
///
/// The counterweight is that a signature must never break a fetch. Every way the
/// local path can fail — switched off, no manifest signature, short media, a
/// failed decode, or two signatures that do not match — has to fall back to the
/// server's offset rather than refusing. Both halves are asserted here.
///
/// `Resolve` is the decision, split out from the decode so it can be driven
/// without FFmpeg or a media file.
///
/// TRACES: UT-053, UT-054, UT-055, UT-056, UT-057 | JR-047
/// </summary>
public class ManifestAlignerTests
{
private const double FeatureRuntime = 7200.0;
private const double ServerOffset = 3.5;
private static readonly string FixtureDir =
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
private static readonly Lazy<string> GoldenSignature = new(() =>
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json")))
.RootElement.GetProperty("signature").GetString()!);
private static readonly Lazy<byte[]> GoldenFrames = new(() =>
AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value)!);
// UT-053
[Fact]
public void ALocalAlignment_SupersedesTheServersOffset()
{
// Same cut, sampled 100 frames apart — a differently trimmed release.
// The server offered 3.5 s from a runtime comparison; the local audio
// says otherwise, and the local answer is the one that gets applied.
const int Shift = 100;
var source = GoldenFrames.Value;
var alignment = ManifestAligner.Resolve(
Signature(source, 44, 1000),
Signature(source, 144, 1000),
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.Local, alignment.Source);
Assert.Equal(MatchTier.Audio, alignment.Tier);
Assert.Equal(Shift, alignment.OffsetFrames);
Assert.Equal(1.0, alignment.Score!.Value);
Assert.Equal(Shift * AudioSignatureMatcher.FrameSeconds, alignment.OffsetSec, 9);
// The server's claim is kept rather than overwritten: the applied offset
// is otherwise unrecoverable once the windows are shifted, and the two
// disagreeing is exactly what someone debugging would need to see.
Assert.Equal(ServerOffset, alignment.ServerOffsetSec);
Assert.Equal(MatchTier.Runtime, alignment.ServerTier);
}
// UT-054
[Fact]
public void TheLocalSignature_IsRecorded_SoALaterFetchNeedNotDecodeAgain()
{
// The decode is the expensive half and the reason signatures are opt-in.
// Keeping the local one beside the truth file is what lets a second
// manifest be aligned for free.
var alignment = ManifestAligner.Resolve(
GoldenSignature.Value,
GoldenSignature.Value,
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(GoldenSignature.Value, alignment.LocalSignature);
Assert.Equal(AlignmentSource.Local, alignment.Source);
Assert.Equal(0.0, alignment.OffsetSec);
}
// UT-055
[Fact]
public void EveryUnavailableLocalPath_FallsBackToTheServer_RatherThanRefusing()
{
// A signature is an enhancement to cut matching. A missing one costs a
// tier; it must never be able to break a fetch, so each of these stores
// the manifest on the server's terms.
var cases = new (string? Local, string? Manifest, string Why)[]
{
(null, GoldenSignature.Value, "signatures off, or the decode failed"),
(GoldenSignature.Value, null, "the manifest carried no signature"),
(null, null, "neither side has one"),
};
foreach (var (local, manifest, why) in cases)
{
var alignment = ManifestAligner.Resolve(
local, manifest, FeatureRuntime, FeatureRuntime, MatchTier.Runtime, ServerOffset);
Assert.Equal(AlignmentSource.Server, alignment.Source);
Assert.Equal(ServerOffset, alignment.OffsetSec);
Assert.Equal(MatchTier.Runtime, alignment.Tier);
Assert.Null(alignment.Score);
Assert.Null(alignment.OffsetFrames);
}
// Short media is the same fallback, reached through JR-044 rather than
// through a missing string: both sides have a perfectly valid signature
// and it is still the runtime that decides.
var shortMedia = ManifestAligner.Resolve(
GoldenSignature.Value,
GoldenSignature.Value,
119.0,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.Server, shortMedia.Source);
Assert.Equal(ServerOffset, shortMedia.OffsetSec);
// A `v2:` signature from a future producer is *un-comparable*, not a
// mismatch (JR-045). Reporting it as one would tell the user their audio
// disagrees with the manifest when all that happened is the producer
// moved ahead of this build.
var futureProducer = ManifestAligner.Resolve(
GoldenSignature.Value,
"v2:" + GoldenSignature.Value[AudioSignature.VersionPrefix.Length..],
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.Server, futureProducer.Source);
Assert.Equal(ServerOffset, futureProducer.OffsetSec);
}
// UT-056
[Fact]
public void TwoSignaturesThatDoNotMatch_AreRecorded_ButStillDoNotBreakTheFetch()
{
// The strongest available hint that a manifest describes different
// content. It is not treated as a failure — the audio may legitimately
// differ, a different language track being the obvious case — but it is
// not discarded either.
var alignment = ManifestAligner.Resolve(
GoldenSignature.Value,
PseudoRandomSignature(1288, seed: 4242),
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.LocalMismatch, alignment.Source);
// The manifest is still stored, on the server's terms.
Assert.Equal(ServerOffset, alignment.OffsetSec);
Assert.Equal(MatchTier.Runtime, alignment.Tier);
}
// UT-057
[Fact]
public void AMismatchOutranksTheTier_InTheCaveatShownToTheUser()
{
// A `runtime`-tier match normally needs no caveat at all, so without
// this the strongest warning available would be the one never shown.
var mismatch = new TruthAlignment { Source = AlignmentSource.LocalMismatch };
var caveat = ManifestConverter.DescribeCaveat(MatchTier.Runtime, 0.0, mismatch);
Assert.NotNull(caveat);
Assert.Contains("does not match", caveat, StringComparison.Ordinal);
// It outranks `loose` too, which would otherwise have claimed the slot
// with the weaker statement — that the runtimes differ, not the audio.
var looseCaveat = ManifestConverter.DescribeCaveat(MatchTier.Loose, 0.0, mismatch);
Assert.Contains("does not match", looseCaveat!, StringComparison.Ordinal);
// A clean local alignment keeps the existing behaviour: a shift is
// explained, an aligned match needs nothing.
var local = new TruthAlignment { Source = AlignmentSource.Local };
Assert.Contains("shifted by", ManifestConverter.DescribeCaveat(MatchTier.Audio, 9.29, local)!, StringComparison.Ordinal);
Assert.Null(ManifestConverter.DescribeCaveat(MatchTier.Audio, 0.0, local));
}
private static string Signature(byte[] source, int start, int count)
=> AudioSignature.VersionPrefix + Convert.ToBase64String(source, start, count);
private static string PseudoRandomSignature(int frames, int seed)
{
var bytes = new byte[frames];
var state = (uint)seed;
for (var i = 0; i < frames; i++)
{
state = (state * 1664525u) + 1013904223u;
bytes[i] = (byte)((((state >> 16) % AudioSignature.NumBands) << 2) | ((state >> 8) & 0x03));
}
return AudioSignature.VersionPrefix + Convert.ToBase64String(bytes);
}
}
@@ -0,0 +1,319 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// Tests for the manifest exchange: tier policy, transport rules, validation on
/// receipt, and offset application.
/// </summary>
public class ManifestExchangeTests
{
private static ManifestServer Server(string url) =>
new() { Url = url, Name = "test", Enabled = true };
private static TitleQuery Query() =>
new() { TmdbId = "504172", RuntimeSec = 6420.5 };
// -----------------------------------------------------------------------
// The withdrawn file-hash tier
// -----------------------------------------------------------------------
[Fact]
public void MatchTierHasNoExactMember()
{
// The `exact` tier keyed on an OpenSubtitles file hash and was withdrawn
// on legal grounds: a file hash identifies the exact release a user
// holds, not the cut the timings describe, so sending one turns a
// catalogue lookup into a release-identification service.
//
// Asserted on the enum rather than trusted, because "we removed it" is
// exactly the kind of decision a later reader re-adds as an oversight.
var names = Enum.GetNames<MatchTier>();
Assert.DoesNotContain("Exact", names);
Assert.Equal(new[] { "Loose", "Runtime", "Audio" }, names);
}
[Fact]
public void AudioIsTheTopTier()
{
// Content-derived, so it identifies the cut rather than the copy — which
// is what makes it an acceptable replacement for the file hash.
Assert.True(MatchTier.Audio > MatchTier.Runtime);
Assert.True(MatchTier.Runtime > MatchTier.Loose);
Assert.Equal(MatchTier.Audio, Enum.GetValues<MatchTier>().Max());
}
[Fact]
public void NoVideoHashIsEverSent()
{
// Structural, not merely policy: `TitleQuery` has no VideoHash property,
// so there is nothing a future caller could populate.
Assert.Null(typeof(TitleQuery).GetProperty("VideoHash"));
var query = new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 };
Assert.DoesNotContain("video_hash", query.ToQueryString(), StringComparison.Ordinal);
}
[Fact]
public void AServerReportingTheWithdrawnTierIsDeclined()
{
// A server may still hold hashes from other clients. If one somehow
// reports `exact`, it is unrecognised rather than silently accepted
// under a tier this plugin has no policy for.
Assert.Null(ManifestExchangeClient.ParseTier("exact"));
Assert.Equal(MatchTier.Audio, ManifestExchangeClient.ParseTier("audio"));
Assert.Equal(MatchTier.Runtime, ManifestExchangeClient.ParseTier("runtime"));
Assert.Equal(MatchTier.Loose, ManifestExchangeClient.ParseTier("loose"));
Assert.Null(ManifestExchangeClient.ParseTier("nonsense"));
}
// -----------------------------------------------------------------------
// Transport
// -----------------------------------------------------------------------
[Theory]
[InlineData("https://jray.tourolle.paris", true)]
[InlineData("https://third.party.example", true)]
[InlineData("http://127.0.0.1:8080", true)]
[InlineData("http://localhost:8080", true)]
[InlineData("http://jray.tourolle.paris", false)]
[InlineData("http://192.168.1.10:8080", false)]
[InlineData("ftp://example.com", false)]
public void HttpsIsRequiredAwayFromLoopback(string url, bool acceptable)
{
// A plaintext server would let any network intermediary rewrite actor
// overlays, and the overlay is shown to the user as fact. Loopback is
// exempt because there is no network path to intercept.
Assert.Equal(acceptable, ManifestExchangeClient.IsTransportAcceptable(new Uri(url)));
}
[Fact]
public void AnUnusableServerUrlYieldsNoRequest()
{
Assert.Null(ManifestExchangeClient.BuildUrl(Server("http://example.com"), "manifests/movie", Query()));
Assert.Null(ManifestExchangeClient.BuildUrl(Server("not a url"), "manifests/movie", Query()));
}
[Fact]
public void QueryParametersAreEscaped()
{
var url = ManifestExchangeClient.BuildUrl(
Server("https://s.example/"),
"manifests/movie",
new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 });
Assert.NotNull(url);
Assert.StartsWith("https://s.example/api/v1/manifests/movie?", url!.AbsoluteUri, StringComparison.Ordinal);
Assert.Contains("tmdb_id=504172", url.AbsoluteUri, StringComparison.Ordinal);
// Invariant formatting, so a comma-decimal locale cannot corrupt the runtime.
Assert.Contains("runtime_sec=6420.5", url.AbsoluteUri, StringComparison.Ordinal);
}
[Fact]
public void EpisodeCoordinatesAreSent()
{
var url = ManifestExchangeClient.BuildUrl(
Server("https://s.example"),
"manifests/episode",
new TitleQuery { SeriesTmdbId = "1396", Season = 2, Episode = 5, RuntimeSec = 2820 });
Assert.NotNull(url);
Assert.Contains("series_tmdb_id=1396", url!.AbsoluteUri, StringComparison.Ordinal);
Assert.Contains("season=2", url.AbsoluteUri, StringComparison.Ordinal);
Assert.Contains("episode=5", url.AbsoluteUri, StringComparison.Ordinal);
}
// -----------------------------------------------------------------------
// Validation on receipt — every server is untrusted
// -----------------------------------------------------------------------
private static Jmanifest ValidManifest()
{
var m = new Jmanifest { JmanifestVersion = 2 };
m.Identity = new JmanifestIdentity { Type = "movie", TmdbId = "504172" };
m.Cut = new JmanifestCut { RuntimeSec = 6420.5 };
var actor = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
actor.Scenes.Add(new JmanifestScene { Start = 191.6, End = 209.2, Belief = 0.98, Route = "live" });
m.Actors.Add(actor);
return m;
}
[Fact]
public void AWellFormedManifestValidates()
{
Assert.True(ManifestValidator.TryValidate(ValidManifest(), 6420.5, out var error), error);
}
[Fact]
public void AnUnknownEnvelopeVersionIsRefused()
{
// Never guessed at: a server one version ahead may have changed the
// meaning of a field this plugin thinks it understands.
foreach (var version in new[] { 0, 1, 3, 99 })
{
var m = ValidManifest();
m.JmanifestVersion = version;
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
Assert.Contains("jmanifest_version", error, StringComparison.Ordinal);
}
}
[Fact]
public void WindowsBeyondTheLocalRuntimeAreRejected()
{
// Bounds are checked against the *local* file, because that is what the
// overlay indexes into. A window past the end is evidence the manifest
// describes another cut.
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 9000 });
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
Assert.Contains("runtime", error, StringComparison.Ordinal);
}
[Fact]
public void InvertedNegativeAndNonFiniteWindowsAreRejected()
{
foreach (var scene in new[]
{
new JmanifestScene { Start = 50, End = 10 },
new JmanifestScene { Start = -1, End = 10 },
new JmanifestScene { Start = double.NaN, End = 10 },
new JmanifestScene { Start = 0, End = double.PositiveInfinity },
})
{
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(scene);
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
}
[Fact]
public void BeliefOutsideZeroToOneIsRejected()
{
foreach (var belief in new[] { -0.1, 1.5, double.NaN })
{
var m = ValidManifest();
m.Actors[0].Scenes[0].Belief = belief;
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
Assert.Contains("belief", error, StringComparison.Ordinal);
}
}
[Fact]
public void MalformedIdentifiersAreRejected()
{
var m = ValidManifest();
m.Actors[0].TmdbId = "884'; DROP TABLE--";
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
m = ValidManifest();
m.Actors[0].ImdbId = "tt0000114"; // a title id in a person field
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
[Fact]
public void ControlCharactersInANameAreRejected()
{
// The overlay renders names as text nodes, so markup is already inert —
// but a bidi override still makes a name display as something other than
// what was stored.
foreach (var name in new[] { "SteveBuscemi", "SteveimecsuB", "SteveBuscemi" })
{
var m = ValidManifest();
m.Actors[0].Name = name;
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
}
[Fact]
public void RealNamesAreAccepted()
{
foreach (var name in new[] { "Steve Buscemi", "Renée Zellweger", "宮崎 駿", "O'Brien" })
{
var m = ValidManifest();
m.Actors[0].Name = name;
Assert.True(ManifestValidator.TryValidate(m, 6420.5, out var error), $"{name}: {error}");
}
}
[Fact]
public void DuplicateActorsAreRejected()
{
var m = ValidManifest();
var dup = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
dup.Scenes.Add(new JmanifestScene { Start = 1, End = 2 });
m.Actors.Add(dup);
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
// -----------------------------------------------------------------------
// Offset application
// -----------------------------------------------------------------------
[Fact]
public void TheOffsetIsAppliedToEveryWindow()
{
// Applied once, at store time, so the stored truth is always in the
// local file's timebase and no reader needs offset awareness.
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 100, End = 120 });
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 200, End = 220 });
var truth = ManifestConverter.ToTruthFile(m, 40, "/media/film.mkv");
Assert.Equal((140.0, 160.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
Assert.Equal((240.0, 260.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
}
[Fact]
public void ANegativeOffsetCannotPushAWindowBelowZero()
{
// A start before the file begins is not indexable by any reader.
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 5, End = 20 });
var truth = ManifestConverter.ToTruthFile(m, -40, "/media/film.mkv");
Assert.Equal(0.0, truth.Actors[0].Scenes[0].Start);
Assert.True(truth.Actors[0].Scenes[0].End >= truth.Actors[0].Scenes[0].Start);
}
[Fact]
public void WindowsAreShiftedNeverReshaped()
{
// A window is a claim about scene membership (SR-002), so merging
// adjacent windows would answer a different question than the pipeline
// answered — "was a face visible" rather than "was the actor present".
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 20 });
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 20, End = 30 });
var truth = ManifestConverter.ToTruthFile(m, 0, "/media/film.mkv");
Assert.Equal(2, truth.Actors[0].Scenes.Count);
Assert.Equal((10.0, 20.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
Assert.Equal((20.0, 30.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
}
[Fact]
public void ALooseMatchSurfacesACaveat()
{
// Applied as a caveat rather than silently: the runtimes differ by up to
// 30 seconds, which is usually a different trim of the same cut but is
// not guaranteed to be.
Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Loose, 0));
Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Audio, 40));
Assert.Null(ManifestConverter.DescribeCaveat(MatchTier.Runtime, 0));
}
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-016 — prioritise/ignore rules resolve by specificity: Item beats Series
/// beats Genre. A scope+value holds only one action, so the only conflicts
/// possible are across scopes, and that is exactly what these cover.
///
/// TRACES: UT-007, UT-008, UT-009, UT-010, UT-011 | JR-016
/// </summary>
public class PolicyResolverTests
{
private static readonly Guid ItemId = Guid.Parse("11111111-1111-1111-1111-111111111111");
private static readonly Guid SeriesId = Guid.Parse("22222222-2222-2222-2222-222222222222");
private static MediaPolicyRule Rule(PolicyScope scope, string value, PolicyAction action)
=> new() { Scope = scope, Value = value, Action = action };
// UT-007
[Fact]
public void Resolve_WithNoMatchingRule_ReturnsNull()
{
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore) };
Assert.Null(PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Drama" }));
}
// UT-008
[Fact]
public void Resolve_ItemRuleBeatsSeriesRule()
{
var rules = new List<MediaPolicyRule>
{
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Ignore),
Rule(PolicyScope.Item, ItemId.ToString("D"), PolicyAction.Prioritise),
};
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, Array.Empty<string>()));
}
// UT-009
[Fact]
public void Resolve_PrioritisedSeriesInsideIgnoredGenre_SeriesWins()
{
// The case that motivated specificity resolution: an admin ignores a
// whole genre but wants one series out of it anyway. If genre won, the
// more specific instruction would be silently discarded.
var rules = new List<MediaPolicyRule>
{
Rule(PolicyScope.Genre, "Anime", PolicyAction.Ignore),
Rule(PolicyScope.Series, SeriesId.ToString("D"), PolicyAction.Prioritise),
};
Assert.Equal(PolicyAction.Prioritise, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "Anime" }));
}
// UT-010
[Fact]
public void Resolve_GenreMatchIsCaseInsensitive()
{
var rules = new List<MediaPolicyRule> { Rule(PolicyScope.Genre, "anime", PolicyAction.Ignore) };
Assert.Equal(PolicyAction.Ignore, PolicyResolver.Resolve(rules, ItemId, SeriesId, new[] { "AnImE" }));
}
// UT-011
[Fact]
public void Resolve_SeriesRuleDoesNotMatchNonEpisode()
{
// A movie carries Guid.Empty as its series id. A series rule whose value
// happened to be an empty GUID must not swallow every movie in the
// library.
var rules = new List<MediaPolicyRule>
{
Rule(PolicyScope.Series, Guid.Empty.ToString("D"), PolicyAction.Ignore),
};
Assert.Null(PolicyResolver.Resolve(rules, ItemId, Guid.Empty, Array.Empty<string>()));
}
}
@@ -0,0 +1,154 @@
using System.Diagnostics;
using System.Linq;
using System.Text.Json;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-004 (windows are scene-membership claims, served verbatim), JR-005 (query
/// semantics and inclusive bounds) and JR-006 (numerous windows).
///
/// These pin the semantics SR-002 sets. The failure they exist to prevent is a
/// well-meaning "tidy-up" — merging adjacent windows, trimming a zero-length
/// one, or collapsing overlaps — each of which silently answers a different
/// question from the one the truth file asked.
///
/// TRACES: UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023 | JR-004, JR-005, JR-006
/// </summary>
public class PresenceLookupTests
{
private static TruthActor Actor(params double[][] windows)
{
var actor = new TruthActor { Name = "Steve Buscemi", TmdbId = "884" };
foreach (var w in windows)
{
actor.Scenes.Add(new TruthScene { Start = w[0], End = w[1] });
}
return actor;
}
// UT-016
[Theory]
[InlineData(12.0)] // exactly the start
[InlineData(30.0)] // inside
[InlineData(45.0)] // exactly the end
public void IsPresentAt_WithinInclusiveBounds_IsPresent(double t)
{
// Both ends inclusive: a window is [start, end], not [start, end).
Assert.True(PresenceLookup.IsPresentAt(Actor([12.0, 45.0]), t));
}
// UT-017
[Theory]
[InlineData(11.999)]
[InlineData(45.001)]
public void IsPresentAt_OutsideBounds_IsAbsent(double t)
{
Assert.False(PresenceLookup.IsPresentAt(Actor([12.0, 45.0]), t));
}
// UT-018
[Fact]
public void IsPresentAt_ZeroLengthWindow_IsPresentAtThatInstant()
{
// A single sighting is a legitimate window. Discarding it as degenerate
// would drop the actor from a scene they are demonstrably in.
Assert.True(PresenceLookup.IsPresentAt(Actor([30.0, 30.0]), 30.0));
}
// UT-019
[Fact]
public void IsPresentAt_OverlappingWindows_IsPresentInsideTheEnclosingOne()
{
// [0,100] encloses [50,60]. A lookup that assumed non-overlapping,
// sorted windows and stopped at the first start > t would miss t = 80.
Assert.True(PresenceLookup.IsPresentAt(Actor([0.0, 100.0], [50.0, 60.0]), 80.0));
}
// UT-020
[Fact]
public void IsPresentAt_UnsortedWindows_StillFindsPresence()
{
// Sortedness is a producer guarantee, not something correctness may
// depend on. A file that violates it must still be read correctly.
var actor = Actor([100.0, 110.0], [10.0, 20.0]);
Assert.True(PresenceLookup.IsPresentAt(actor, 15.0));
Assert.False(PresenceLookup.WindowsAreSorted(actor));
}
// UT-021
[Fact]
public void ActorsPresentAt_AdjacentWindowsAreNeverMerged()
{
// [0,10] and [10,20] look mergeable. They must not be merged: two
// windows mean a genuine departure and return, and the plugin does not
// reinterpret that claim. The actor is reported once, from two windows.
var truth = new TruthFile();
truth.Actors.Add(Actor([0.0, 10.0], [10.0, 20.0]));
Assert.Single(PresenceLookup.ActorsPresentAt(truth, 10.0));
Assert.Equal(2, truth.Actors[0].Scenes.Count);
}
// UT-022
[Fact]
public void TruthFile_RoundTrips_WithWindowsByteIdentical()
{
// JR-004: served exactly as given. A round trip through the serializer
// is where a silent normalisation would show up.
const string Json = """
{"schema_version":2,"movie":"/m.mkv",
"extraction":{"sample_fps":1,"extinction_sec":12},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"A","imdb_id":"","tmdb_id":"884","jellyfin_id":"",
"scenes":[{"start":0.0,"end":10.0},
{"start":10.0,"end":20.0},
{"start":30.0,"end":30.0}]}]}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(Json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
var windows = parsed.Actors[0].Scenes;
Assert.Equal(3, windows.Count);
Assert.Equal((0.0, 10.0), (windows[0].Start, windows[0].End));
Assert.Equal((10.0, 20.0), (windows[1].Start, windows[1].End));
Assert.Equal((30.0, 30.0), (windows[2].Start, windows[2].End));
}
// UT-023
[Fact]
public void ActorsPresentAt_WithManyWindows_StaysCheapAndBoundsTheResponse()
{
// SR-002: windows may be numerous; consumers must not assume a handful
// of long ones. Track-extent presence with a short re-acquisition
// timeout produces many short windows per actor.
var truth = new TruthFile();
for (var a = 0; a < 50; a++)
{
var actor = Actor();
for (var w = 0; w < 1000; w++)
{
actor.Scenes.Add(new TruthScene { Start = w * 10.0, End = (w * 10.0) + 4.0 });
}
truth.Actors.Add(actor);
}
var sw = Stopwatch.StartNew();
var present = PresenceLookup.ActorsPresentAt(truth, 5002.0).ToList();
sw.Stop();
// The response is bounded by actor count, never by window count — which
// is what keeps `jray?t=` small however finely presence is sliced.
Assert.Equal(50, present.Count);
// 50 000 windows scanned. Generous bound: this asserts the read path is
// not accidentally quadratic, not a precise budget on a shared runner.
Assert.True(sw.ElapsedMilliseconds < 250, $"lookup took {sw.ElapsedMilliseconds} ms");
}
}
@@ -0,0 +1,229 @@
using System.Text.Json;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-002 (the <c>schema_version</c> 2 shape) and JR-003 (an unknown version is
/// refused, never guessed).
///
/// These are the flag-day tests. v1 is gone rather than deprecated, so what has
/// to be pinned is not only that v2 parses but that v1 does *not* quietly
/// half-parse into something a reader would treat as real.
///
/// TRACES: UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037 | JR-002, JR-003
/// </summary>
public class TruthSchemaTests
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
private const string V2 = """
{"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":"Steve Buscemi","imdb_id":"nm0000114","tmdb_id":"884",
"jellyfin_id":"abc123-guid",
"scenes":[{"start":191.6,"end":209.2,"belief":0.98,"route":"live"},
{"start":438.2,"end":465.6,"belief":0.81,"route":"deferred"}]}]}
""";
// UT-029
[Fact]
public void AV2FileRoundTripsWithProvenanceAndCutIntact()
{
var parsed = JsonSerializer.Deserialize<TruthFile>(V2, Options)!;
Assert.Equal(2, parsed.SchemaVersion);
Assert.Equal("/data/movies/Movie.mkv", parsed.Movie);
// sample_fps moved into `extraction` in the bump. Reading it from the
// top level would silently yield zero.
Assert.Equal(5, parsed.Extraction!.SampleFps);
Assert.Equal(12, parsed.Extraction.ExtinctionSec);
Assert.Equal(1820, parsed.Extraction.GallerySize);
Assert.Equal("global", parsed.Extraction.GalleryScope);
Assert.Equal(6420.5, parsed.Cut!.RuntimeSec);
Assert.Equal("v1:v7fA3k", parsed.Cut.AudioSignature);
}
// UT-030
[Fact]
public void ScenesAreObjectsThatRetainBeliefAndRoute()
{
// The reason `scenes` stopped being float pairs. A window that loses its
// belief and route is indistinguishable from a v1 window, which is
// exactly the regression this pins.
var parsed = JsonSerializer.Deserialize<TruthFile>(V2, Options)!;
var windows = parsed.Actors[0].Scenes;
Assert.Equal(2, windows.Count);
Assert.Equal(0.98, windows[0].Belief);
Assert.Equal("live", windows[0].Route);
Assert.Equal(0.81, windows[1].Belief);
Assert.Equal("deferred", windows[1].Route);
}
// UT-031
[Theory]
[InlineData("live")]
[InlineData("deferred")]
[InlineData("pooled")]
public void AllThreeRoutesSurviveARoundTrip(string route)
{
// All three are named by extraction AR-017. A serializer that dropped an
// unrecognised one would make `pooled` claims look live.
var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion };
var actor = new TruthActor { Name = "A", TmdbId = "884" };
actor.Scenes.Add(new TruthScene { Start = 1, End = 2, Belief = 0.5, Route = route });
truth.Actors.Add(actor);
var round = JsonSerializer.Deserialize<TruthFile>(JsonSerializer.Serialize(truth, Options), Options)!;
Assert.Equal(route, round.Actors[0].Scenes[0].Route);
}
// UT-032
[Fact]
public void AWindowWithoutBeliefIsNullRatherThanZero()
{
// Absent and "believed with probability zero" are different statements.
// Defaulting to 0.0 would make an unannotated window look maximally
// untrustworthy, and a consumer ranking on belief would discard it.
const string NoBelief = """
{"schema_version":2,"movie":"/m.mkv",
"actors":[{"name":"A","tmdb_id":"884","scenes":[{"start":1,"end":2}]}]}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(NoBelief, Options)!;
Assert.Null(parsed.Actors[0].Scenes[0].Belief);
Assert.Null(parsed.Actors[0].Scenes[0].Route);
}
// UT-033 — JR-003, the flag day itself.
[Theory]
[InlineData(1)]
[InlineData(3)]
[InlineData(0)]
public void AnUnsupportedVersionIsRefused(int version)
{
// Both directions matter. v1 is the version that exists in the wild, and
// v3 is a future producer this build cannot know the shape of — guessing
// at either is what JR-003 forbids.
var truth = new TruthFile { SchemaVersion = version };
Assert.False(TruthSchema.IsSupported(truth));
Assert.Contains(version.ToString(System.Globalization.CultureInfo.InvariantCulture), TruthSchema.DescribeRejection(version), System.StringComparison.Ordinal);
}
// UT-034
[Fact]
public void AMissingSchemaVersionIsRefusedRatherThanAssumedCurrent()
{
// An absent field deserialises to 0. Treating that as "probably the
// current version" is the single most tempting mistake here, and it
// would accept any malformed document that happened to parse.
const string NoVersion = """
{"movie":"/m.mkv","actors":[]}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(NoVersion, Options);
Assert.False(TruthSchema.IsSupported(parsed));
}
// UT-035
[Fact]
public void ANullTruthFileIsRefusedWithoutThrowing()
{
// `null` reaches this from a file containing the literal `null`, which
// parses successfully. The check must reject it rather than dereference.
Assert.False(TruthSchema.IsSupported(null));
}
// UT-037
[Fact]
public void TheProducersActualOutputParses()
{
// Byte-for-byte the shape `scene-actor-extraction` writes today
// (`src/nodes/result_sink_node.hpp`, IR-002) — *not* the fully populated
// example from the spec. It omits `cut` entirely and carries only three
// of the five `extraction` fields.
//
// This is the test that would have caught the break: the plugin read v1
// while the pipeline had already moved to v2, so nothing the pipeline
// produced could be read at all. A round-trip test written against the
// spec's example alone would have passed throughout.
const string AsProduced = """
{
"schema_version": 2,
"movie": "/data/movies/Film.mkv",
"extraction": {
"sample_fps": 5.0,
"extinction_sec": 12.0,
"gallery_scope": "global"
},
"actors": [
{
"name": "Steve Buscemi",
"imdb_id": "nm0000114",
"tmdb_id": "884",
"jellyfin_id": "",
"scenes": [
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" }
]
}
]
}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(AsProduced, Options);
Assert.True(TruthSchema.IsSupported(parsed));
Assert.Equal(5.0, parsed!.Extraction!.SampleFps);
Assert.Equal(12.0, parsed.Extraction.ExtinctionSec);
Assert.Equal("global", parsed.Extraction.GalleryScope);
// Absent blocks and fields are null, not defaults that would read as data.
Assert.Null(parsed.Cut);
Assert.Null(parsed.Extraction.GallerySize);
Assert.Null(parsed.Extraction.PipelineVersion);
var window = Assert.Single(parsed.Actors[0].Scenes);
Assert.Equal((191.6, 209.2), (window.Start, window.End));
Assert.Equal(0.98, window.Belief);
Assert.Equal("live", window.Route);
}
// UT-036
[Fact]
public void AV1FileDoesNotHalfParseIntoUsableWindows()
{
// The load-bearing claim of the flag day. v1 `scenes` were float pairs,
// so a v1 file either fails to deserialise or produces windows that are
// not usable — what must never happen is silent success with windows at
// 0,0, which would report actors present at the start of every film.
const string V1 = """
{"schema_version":1,"movie":"/m.mkv","sample_fps":1,"anneal_sec":2,
"actors":[{"name":"A","tmdb_id":"884","scenes":[[0.0,10.0],[10.0,20.0]]}]}
""";
TruthFile? parsed = null;
try
{
parsed = JsonSerializer.Deserialize<TruthFile>(V1, Options);
}
catch (JsonException)
{
// The expected path: a float pair is not an object.
return;
}
// If it did parse, the version gate is what stops it being used.
Assert.False(TruthSchema.IsSupported(parsed));
}
}
@@ -0,0 +1,92 @@
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-022 — an earlier JRay injected its overlay script into index.html on
/// disk. Those users must not be left with a stale injection pointing at
/// endpoints that have since changed, so removal survives even though JR-021
/// deleted the injection that created it.
///
/// Removal keys on JRay's own marker. The cases that matter are the ones where
/// it could reach too far: another plugin's injection, or a script tag that
/// looks like JRay's but carries no marker.
///
/// TRACES: UT-001, UT-002, UT-003, UT-004, UT-005 | JR-022
/// </summary>
public class WebClientPatchServiceTests
{
private const string Marker = "<!-- jray-overlay -->";
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
// UT-001
[Fact]
public void RemoveInjection_WithMarkedTagAndNewline_RestoresOriginalBytes()
{
var original = "<html><body><div>x</div>\n</body></html>";
var patched = "<html><body><div>x</div>\n" + ScriptTag + Marker + "\n</body></html>";
// The trailing newline goes with the tag. If it did not, every
// install/uninstall cycle would leave another blank line behind.
Assert.Equal(original, WebClientPatchService.RemoveInjection(patched));
}
// UT-002
[Fact]
public void RemoveInjection_WithMarkedTagAndNoNewline_RemovesTag()
{
var patched = "<html><body>" + ScriptTag + Marker + "</body></html>";
Assert.Equal("<html><body></body></html>", WebClientPatchService.RemoveInjection(patched));
}
// UT-003
[Fact]
public void RemoveInjection_WithNoMarker_LeavesDocumentUnchanged()
{
var clean = "<html><body><div>x</div>\n</body></html>";
Assert.Equal(clean, WebClientPatchService.RemoveInjection(clean));
}
// UT-004
[Fact]
public void RemoveInjection_IsIdempotent()
{
var patched = "<html><body>" + ScriptTag + Marker + "\n</body></html>";
var once = WebClientPatchService.RemoveInjection(patched);
var twice = WebClientPatchService.RemoveInjection(once);
// Startup calls this unconditionally, so it runs on every boot forever
// after the patch is gone.
Assert.Equal(once, twice);
}
// UT-005
[Fact]
public void RemoveInjection_LeavesAnotherPluginsInjectionIntact()
{
var foreign = "<script defer src=\"/Plugins/Other/Script\"></script><!-- other-overlay -->";
var patched = "<html><body>" + foreign + ScriptTag + Marker + "\n</body></html>";
var cleaned = WebClientPatchService.RemoveInjection(patched);
// The marker is what makes removal unambiguous. Removing anything we did
// not add is the failure this guards: it is another plugin's file too.
Assert.Contains(foreign, cleaned, System.StringComparison.Ordinal);
Assert.DoesNotContain(Marker, cleaned, System.StringComparison.Ordinal);
}
// UT-006
[Fact]
public void RemoveInjection_WithUnmarkedLookalikeTag_LeavesItAlone()
{
// Same script tag, no marker: JRay did not write this, so JRay does not
// remove it.
var patched = "<html><body>" + ScriptTag + "\n</body></html>";
Assert.Equal(patched, WebClientPatchService.RemoveInjection(patched));
}
}
@@ -0,0 +1,194 @@
{
"_": "Golden vector for the JRay v1 audio signature (JRay-public-server SPEC.md \u00a73). Shared verbatim between scene-actor-extraction (C++) and the jRay Jellyfin plugin (C#) so the two implementations can be proven bit-identical. IR-004, IR-005, IR-007, IR-008.",
"version": "v1",
"signature": "v1:AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAeHx8eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh4eHh8fHzk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5OTk5V1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dXV1dycnJycnJycnJycnJycnJycnJycnJycnJycnJycnMPDgwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKysrKytFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRUVFRWNjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2NjY2Njfn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5+fn5/GxoZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGRkZGTc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3Nzc3UlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSU1JsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbGxsbAoLCwoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCgoKCwsLJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSUlJSVDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ0NDQ15eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eX19eeXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXl5eXkXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFzExMTExMTExMTExMTExMTExMTExMTExMTExMTExT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09PT09qampqampqampqampqampqampqampqampqampqamsHBwUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyM+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4+Pj4/PlhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYWFhYd3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3d3ExMRERERERERERERERERERERERERERERERERERERES8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vLy8vSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpKSkpLS0plZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZQMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDAwMDHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0dHR0eHh44ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4ODg4OFdXV1ZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWVlZWV1dXcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXFxcXEPDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDw8PDyoqKioqKioqKioqKioqKioqKioqKioqKioqKioqKysqRERERERERERERERERERERERERERERERERERERERjY2NjY2NjY2NjYw==",
"frame_count": 1288,
"media": {
"file": "jray_audio_v1_tone.flac",
"generator": "make_fixture.py",
"container": "FLAC (lossless \u2014 decodes to exactly the PCM make_fixture.py emits)",
"duration_sec": 120.0,
"sample_rate": 11025,
"channels": 1,
"sample_format": "s16",
"sha256": "912ecd426cd426dccb37753e0249694227619c701cb9f533502b37da0fbe8096",
"bytes": 585142
},
"decoded_window": {
"_": "Checksums of the 120 s centre window after downmix to mono and resample to 11025 Hz, i.e. exactly the stream `ffmpeg -ss <mid-60> -t 120 -i <file> -vn -ac 1 -ar 11025 -f f32le -` produces. Check these first: a mismatch here is a decode problem, not a DSP one.",
"samples": 1323000,
"f32le_fnv1a64": "0x1ef7899cd4d12662",
"s16le_fnv1a64": "0xf824fa56f125c0dc"
},
"params": {
"window_sec": 120.0,
"window_centre": "runtime/2, i.e. samples from runtime/2 - 60 s; truncated to exactly 1323000 samples",
"min_duration_sec": 120.0,
"min_duration_rule": "IR-007 \u2014 below this emit NO signature and apply no sync offset",
"sample_rate": 11025,
"channels": 1,
"arithmetic": "IEEE-754 double throughout; float32 is not sufficient",
"sample_scale": "s16 * (1/32768), FFmpeg's native s16->flt",
"frame_size": 4096,
"hop_size": 1024,
"frame_count_rule": "1 + (n_samples - 4096) / 1024, integer division; whole frames only",
"window_fn": "Hann, PERIODIC: w[n] = 0.5 * (1 - cos(2*pi*n/4096))",
"transform": "radix-2 DIT complex FFT over the 4096 real samples (imag=0), no normalisation",
"magnitude": "sqrt(re^2 + im^2), linear",
"band_lo_hz": 300.0,
"band_hi_hz": 3000.0,
"num_bands": 32,
"band_edges": "edge[b] = 300 * (3000/300)^(b/32), b = 0..32",
"band_bins": "band b owns FFT bins [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] * 4096 / 11025); see band_fft_bins",
"band_value": "MEAN of the linear magnitudes in the band (not sum, not max)",
"peak_bin": "argmax over the 32 band values; ties resolve to the LOWEST index",
"energy_metric": "E = mean magnitude over all FFT bins 112..1114, i.e. the whole 300-3000 Hz band",
"energy_reference": "upper median of E over all frames: sorted[n/2], no averaging of the two middle values",
"energy_ratio": "r = log10((E + 1e-12) / (E_ref + 1e-12))",
"energy_class_edges": [
-0.6,
-0.2,
0.2
],
"energy_class": "0 if r < -0.6, 1 if r < -0.2, 2 if r < 0.2, else 3",
"byte_layout": "bit7 = 0 (reserved), bits6..2 = 5-bit band index, bits1..0 = 2-bit energy class; byte = (band << 2) | class",
"base64": "standard alphabet A-Za-z0-9+/ with '=' padding",
"prefix": "v1:"
},
"band_fft_bins": [
[
112,
120
],
[
120,
129
],
[
129,
139
],
[
139,
149
],
[
149,
160
],
[
160,
172
],
[
172,
185
],
[
185,
199
],
[
199,
213
],
[
213,
229
],
[
229,
246
],
[
246,
265
],
[
265,
285
],
[
285,
306
],
[
306,
328
],
[
328,
353
],
[
353,
379
],
[
379,
408
],
[
408,
438
],
[
438,
471
],
[
471,
506
],
[
506,
543
],
[
543,
584
],
[
584,
627
],
[
627,
674
],
[
674,
724
],
[
724,
778
],
[
778,
836
],
[
836,
899
],
[
899,
966
],
[
966,
1038
],
[
1038,
1115
]
],
"notes": [
"The server spec fixes the window, rate, STFT geometry, band and the 5+2 bit packing. Everything under params beyond that (Hann periodicity, band aggregation, the energy-class definition, tie-breaking, base64 alphabet) is pinned HERE for v1 \u2014 the spec does not constrain it, and two implementations that guess differently produce non-matching signatures.",
"Decision margins on this fixture: the two strongest bands are within 1.3% on the closest frame, and the closest frame to an energy-class edge is 3.6e-3 away in log10. Both are many orders of magnitude above double-precision FFT differences, so any two correct double- precision implementations agree; a float32 implementation is not guaranteed to.",
"Coverage: all 32 bands and all 4 energy classes appear in the golden signature.",
"Robustness observed on this fixture: identical peak-bin sequence after a stereo/44100 Hz round trip and after AAC 128 kbit/s re-encoding."
]
}
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""Regenerate the JRay audio-signature golden fixture.
python3 make_fixture.py # writes jray_audio_v1_tone.flac here
This is the *source of truth* for the fixture media: `jray_audio_v1_tone.flac`
is a lossless FLAC encoding of exactly the PCM this script emits, so any repo
that wants to check its own audio-signature implementation against the golden
vector in `jray_audio_v1_golden.json` can regenerate the input from scratch and
confirm it is byte-identical (the golden file records `pcm_fnv1a64`, a hash of
the decoded 16-bit samples).
Deliberately dependency-free (no numpy) and written in plain arithmetic so it
ports to any language in ~20 lines.
Signal — 120.000 s, mono, 11025 Hz, 16-bit signed PCM:
* split into segments of 32768 samples (~2.97 s), 40.4 segments in total;
* segment `s` carries one sine at the geometric centre of log-band
`(s * 7) mod 32` of the 300-3000 Hz band, so all 32 bands are exercised;
* its amplitude walks a golden-ratio low-discrepancy sequence over
[10^-1.55, 10^-0.02] so frame energies spread continuously across ~1.5
decades and all four energy classes are exercised, without a dense cluster
of frames sitting on a class boundary;
* phase is carried across segment boundaries (no clicks);
* a constant, far quieter 777 Hz tone sits underneath so no frame is
degenerate;
* samples are quantised with floor(x * 32767 + 0.5).
Why FLAC and not WAV: 120 s of 11025 Hz 16-bit PCM is 2.6 MB and does not
compress in git. FLAC is lossless — FFmpeg decodes it to exactly the PCM
written here — and is ~3.5x smaller. `--wav` writes the uncompressed original
if you want to diff it.
"""
import math
import struct
import subprocess
import sys
import os
SAMPLE_RATE = 11025
DURATION_SEC = 120.0
SEGMENT = 32768 # samples per tone segment
BAND_STRIDE = 7 # coprime with 32 -> visits every band
BAND_LO_HZ = 300.0
BAND_HI_HZ = 3000.0
NUM_BANDS = 32
AMP_LOG_MIN = -1.55 # 10^-1.55 ~= 0.028
AMP_LOG_SPAN = 1.53 # up to 10^-0.02 ~= 0.955
PHI_FRAC = 0.6180339887498949
BG_HZ = 777.0
BG_AMP = 0.004
OUT_FLAC = "jray_audio_v1_tone.flac"
OUT_WAV = "jray_audio_v1_tone.wav"
def generate():
"""Return the 120 s signal as a list of int16 sample values."""
n = int(round(SAMPLE_RATE * DURATION_SEC))
out = [0] * n
phase = 0.0
two_pi = 2.0 * math.pi
for start in range(0, n, SEGMENT):
s = start // SEGMENT
end = min(n, start + SEGMENT)
band = (s * BAND_STRIDE) % NUM_BANDS
# geometric centre of log-band `band`
freq = BAND_LO_HZ * (BAND_HI_HZ / BAND_LO_HZ) ** ((band + 0.5) / NUM_BANDS)
amp = 10.0 ** (AMP_LOG_MIN + AMP_LOG_SPAN * ((s * PHI_FRAC) % 1.0))
step = two_pi * freq / SAMPLE_RATE
for k in range(end - start):
i = start + k
x = amp * math.sin(phase + step * k)
x += BG_AMP * math.sin(two_pi * BG_HZ * i / SAMPLE_RATE)
if x > 1.0:
x = 1.0
elif x < -1.0:
x = -1.0
out[i] = int(math.floor(x * 32767.0 + 0.5))
phase = (phase + step * (end - start)) % two_pi
return out
def write_wav(path, samples):
data = struct.pack("<%dh" % len(samples), *samples)
hdr = b"RIFF" + struct.pack("<I", 36 + len(data)) + b"WAVE"
hdr += b"fmt " + struct.pack("<IHHIIHH", 16, 1, 1, SAMPLE_RATE,
SAMPLE_RATE * 2, 2, 16)
hdr += b"data" + struct.pack("<I", len(data))
with open(path, "wb") as fh:
fh.write(hdr + data)
def main():
here = os.path.dirname(os.path.abspath(__file__))
samples = generate()
wav = os.path.join(here, OUT_WAV)
write_wav(wav, samples)
if "--wav" in sys.argv:
print("wrote", wav)
return
flac = os.path.join(here, OUT_FLAC)
# -compression_level 12 is deterministic for a given libFLAC/ffmpeg build;
# only the container bytes vary, never the decoded PCM.
subprocess.run(["ffmpeg", "-nostdin", "-v", "error", "-y", "-i", wav,
"-c:a", "flac", "-compression_level", "12", flac],
check=True)
os.remove(wav)
print("wrote", flac)
if __name__ == "__main__":
main()
+19 -2
View File
@@ -1,7 +1,9 @@
Microsoft Visual Studio Solution File, Format Version 12.00
#
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay", "Jellyfin.Plugin.JRay\Jellyfin.Plugin.JRay.csproj", "{D921B930-CF91-406F-ACBC-08914DCD0D34}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Jellyfin.Plugin.JRay.Tests", "Jellyfin.Plugin.JRay.Tests\Jellyfin.Plugin.JRay.Tests.csproj", "{104C1021-3155-4404-9CA0-8ED8F310A152}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -12,7 +14,7 @@ Global
Release|x86 = Release|x86
EndGlobalSection
GlobalSection(ProjectConfigurationPlatforms) = postSolution
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.ActiveCfg = Debug|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Debug|x64.Build.0 = Debug|Any CPU
@@ -24,5 +26,20 @@ Global
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x64.Build.0 = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.ActiveCfg = Release|Any CPU
{D921B930-CF91-406F-ACBC-08914DCD0D34}.Release|x86.Build.0 = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|Any CPU.Build.0 = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.ActiveCfg = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x64.Build.0 = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.ActiveCfg = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Debug|x86.Build.0 = Debug|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.ActiveCfg = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|Any CPU.Build.0 = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.ActiveCfg = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x64.Build.0 = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.ActiveCfg = Release|Any CPU
{104C1021-3155-4404-9CA0-8ED8F310A152}.Release|x86.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
EndGlobal
@@ -0,0 +1,129 @@
namespace Jellyfin.Plugin.JRay.Configuration;
/// <summary>
/// How far a configured manifest server is trusted. See the JRay public server
/// specification, §9 "Trusting third-party servers".
/// </summary>
public enum ServerTrustLevel
{
/// <summary>
/// Accept manifests, but never contribute to this server and never send
/// library inventory beyond the single item being queried. The default for
/// user-added servers.
/// </summary>
FetchOnly = 0,
/// <summary>
/// Eligible to contribute to, subject to <see cref="ManifestServer.AllowContribute"/>.
/// </summary>
Full = 1,
}
/// <summary>
/// The minimum cut-match tier a fetched manifest must reach before it is stored.
/// See the public server specification, §3 "Cut matching".
/// </summary>
/// <remarks>
/// Every tier is a claim about a <em>cut</em>, never about a copy. There is no
/// file-level tier, and the plugin sends no <c>video_hash</c>.
/// <para>
/// <b>The <c>Exact</c> tier was withdrawn for legal reasons; do not re-add it
/// without an explicit, recorded agreement.</b> It keyed on the OpenSubtitles
/// file hash, which identifies the individual encode a user holds rather than
/// the edit the timings describe. A TMDB id discloses "some copy of this film";
/// a file hash discloses "<em>this exact release</em>", which turns a catalogue
/// lookup into a release-identification service and turns the server's database
/// into a mapping from file fingerprints to the instances holding them. That is
/// a far more specific disclosure than PR-005 permits, and a dataset no
/// volunteer operator should be holding.
/// </para>
/// <para>
/// The audio signature is the deliberate replacement: derived from content, it
/// identifies the <em>cut</em>, so two different encodes of the same edit agree.
/// It answers the question the exchange needs — "do these timings apply to this
/// media?" — without answering the one it must not. <c>Audio</c> is therefore
/// the top tier here.
/// </para>
/// </remarks>
public enum MatchTier
{
/// <summary>Audio 0.600.85, or runtimes within ±30s. Surfaced as a caveat in the UI.</summary>
Loose = 0,
/// <summary>Runtimes within ±2s.</summary>
Runtime = 1,
/// <summary>Audio signature score ≥ 0.85; may carry a non-zero offset. The top tier.</summary>
Audio = 2,
}
/// <summary>
/// One entry in the ordered list of manifest servers the plugin queries
/// (public server specification, §9 "Multiple servers").
/// </summary>
/// <remarks>
/// The list is ordered because order *is* the user's trust ranking, made
/// explicit: for a fetch, servers are tried in order and the first acceptable
/// result wins. Querying every server for every item would multiply egress and
/// leak the library to more parties.
///
/// <c>FetchOnly</c> is the default for user-added servers. Adding a third-party
/// server means trusting its operator not to serve deliberately wrong actor
/// data — the client-side controls bound the damage to bad overlay content,
/// they cannot make wrong data right.
/// </remarks>
// TRACES: JR-025 | PR-005, PR-006
public class ManifestServer
{
/// <summary>
/// Initializes a new instance of the <see cref="ManifestServer"/> class.
/// </summary>
public ManifestServer()
{
Url = string.Empty;
Name = string.Empty;
Token = string.Empty;
Enabled = false;
AllowContribute = false;
TrustLevel = ServerTrustLevel.FetchOnly;
}
/// <summary>
/// Gets or sets the base URL of the server, e.g. "https://jray.tourolle.paris".
/// HTTPS is required for non-loopback servers: a plaintext server would let
/// any network intermediary rewrite actor overlays.
/// </summary>
public string Url { get; set; }
/// <summary>
/// Gets or sets the display label shown in the configuration page.
/// </summary>
public string Name { get; set; }
/// <summary>
/// Gets or sets the API token used to contribute manifests. Optional —
/// required only to contribute, never to fetch. This is an anonymous bearer
/// capability rather than an account (public server specification, §5a).
/// </summary>
public string Token { get; set; }
/// <summary>
/// Gets or sets a value indicating whether this server is queried at all.
/// Lets an admin disable an entry without deleting it and losing its token.
/// </summary>
public bool Enabled { get; set; }
/// <summary>
/// Gets or sets a value indicating whether locally generated manifests may be
/// contributed to this server. Independent of fetching, and off by default:
/// contribution is never fanned out, because broadcasting uploads to every
/// configured server would multiply privacy exposure without the user
/// intending it.
/// </summary>
public bool AllowContribute { get; set; }
/// <summary>
/// Gets or sets how far this server is trusted.
/// </summary>
public ServerTrustLevel TrustLevel { get; set; }
}
@@ -1,3 +1,4 @@
using System.Collections.ObjectModel;
using MediaBrowser.Model.Plugins;
namespace Jellyfin.Plugin.JRay.Configuration;
@@ -5,8 +6,28 @@ namespace Jellyfin.Plugin.JRay.Configuration;
/// <summary>
/// Plugin configuration.
/// </summary>
/// <remarks>
/// Every manifest-exchange switch here defaults to <b>off</b>, including the
/// pre-configured community server, so no traffic leaves an installation until
/// an admin acts. Fetching and contributing each reveal to a server operator
/// that some instance holds a given title; that is inherent to the exchange, so
/// the defaults bound the exposure rather than pretending to remove it.
/// </remarks>
// TRACES: JR-036, JR-038 | PR-005
public class PluginConfiguration : BasePluginConfiguration
{
/// <summary>
/// The community manifest exchange. Shipped pre-configured but
/// <b>disabled</b>, so no traffic leaves an installation until an admin opts
/// in (public server specification, §9).
/// </summary>
public const string CommunityServerUrl = "https://jray.tourolle.paris";
/// <summary>
/// Display name for <see cref="CommunityServerUrl"/>.
/// </summary>
public const string CommunityServerName = "JRay Community";
/// <summary>
/// Initializes a new instance of the <see cref="PluginConfiguration"/> class.
/// </summary>
@@ -15,6 +36,24 @@ public class PluginConfiguration : BasePluginConfiguration
TruthFileSuffix = ".jray.json";
CacheDurationMinutes = 60;
EnableOverlay = true;
// Manifest sharing is a network egress feature, so every part of it is
// off by default (public server specification, §9 "Configuration").
EnableManifestSharing = false;
ContributeManifests = false;
ComputeAudioSignatures = false;
MinimumMatchTier = MatchTier.Runtime;
// Pre-configured but disabled: the admin opts in by enabling it, rather
// than by having to discover and type a URL.
Servers.Add(new ManifestServer
{
Url = CommunityServerUrl,
Name = CommunityServerName,
Enabled = false,
AllowContribute = false,
TrustLevel = ServerTrustLevel.FetchOnly,
});
}
/// <summary>
@@ -37,4 +76,62 @@ public class PluginConfiguration : BasePluginConfiguration
/// any previously injected script is removed.
/// </summary>
public bool EnableOverlay { get; set; }
/// <summary>
/// Gets or sets a value indicating whether JRay may fetch actor-timeline
/// manifests from the configured servers. Off by default — this is a network
/// egress feature and must be opt-in.
/// </summary>
/// <remarks>
/// Fetching reveals to a server operator that some instance holds a given
/// title. That is inherent to the exchange, and each configured server
/// multiplies the exposure, which the configuration page states plainly.
/// </remarks>
public bool EnableManifestSharing { get; set; }
/// <summary>
/// Gets or sets a value indicating whether locally generated manifests may be
/// contributed back. A separate opt-in from downloading, and off by default.
/// </summary>
/// <remarks>
/// Contribution additionally requires <see cref="ManifestServer.AllowContribute"/>
/// on the specific server and a token for it. Uploads are never fanned out to
/// every configured server.
/// </remarks>
public bool ContributeManifests { get; set; }
/// <summary>
/// Gets or sets the minimum cut-match tier a fetched manifest must reach
/// before it is stored.
/// </summary>
/// <remarks>
/// Defaults to <see cref="MatchTier.Runtime"/>. <see cref="MatchTier.Loose"/>
/// admits manifests whose runtime differs by up to 30s, which may be a
/// different trim of the same cut — usable, but it should be surfaced as a
/// caveat rather than applied silently.
/// </remarks>
public MatchTier MinimumMatchTier { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the plugin computes audio
/// signatures for library items, enabling content-based cut matching and
/// identification of files whose providence is unknown.
/// </summary>
/// <remarks>
/// Off by default. Uses the FFmpeg binary Jellyfin already ships (via
/// <c>IMediaEncoder.EncoderPath</c>), so there is no extra dependency, but it
/// costs roughly a second or two of I/O per item and is therefore opt-in.
/// </remarks>
public bool ComputeAudioSignatures { get; set; }
/// <summary>
/// Gets the ordered list of manifest servers.
/// </summary>
/// <remarks>
/// Order is the user's trust ranking: for a fetch, servers are tried in order
/// and the first result clearing <see cref="MinimumMatchTier"/> wins. For a
/// series, first-match applies per <i>episode</i>, so a later server is
/// queried only for the episodes earlier ones lacked.
/// </remarks>
public Collection<ManifestServer> Servers { get; } = new();
}
@@ -29,16 +29,82 @@
<span>Enable pause overlay</span>
</label>
<div class="fieldDescription">
Injects a small script into the web client that shows on-screen actors
when playback is paused. Disabling this removes the injected script.
Shows the cast of the current scene when playback is paused. This
requires the
<a is="emby-linkbutton" class="button-link" href="https://github.com/IAmParadox27/jellyfin-plugin-file-transformation" target="_blank" rel="noopener">File Transformation</a>
plugin, which rewrites the web client's index.html as it is served.
JRay never modifies index.html on disk, so there is no fallback if
that plugin is absent — only the overlay is affected, and every
other JRay feature keeps working.
</div>
</div>
<div id="JRayDependencyStatus" class="fieldDescription" style="margin:0 0 1.5em;padding:0.75em 1em;border-radius:0.25em;display:none;"></div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
<h2 style="margin-top:2em;">Coverage</h2>
<p class="fieldDescription">
How much of your library has scene-actor truth data. Ignored items are
excluded from the "to&nbsp;do" total, so blacklisting a genre or series
won't drag your progress down.
</p>
<div id="JRayCoverageSummary" style="margin-bottom:1em;">Loading…</div>
<div id="JRayCoverageBar" style="height:1.25em;border-radius:0.25em;overflow:hidden;background:rgba(127,127,127,0.25);display:flex;margin-bottom:0.5em;"></div>
<div id="JRayCoverageLegend" class="fieldDescription" style="margin-bottom:1.5em;"></div>
<details style="margin-bottom:1.5em;">
<summary style="cursor:pointer;">By media type</summary>
<div id="JRayCoverageByType" style="margin-top:0.5em;"></div>
</details>
<details style="margin-bottom:1.5em;">
<summary style="cursor:pointer;">By genre</summary>
<div id="JRayCoverageByGenre" style="margin-top:0.5em;"></div>
</details>
<h2 style="margin-top:2em;">Prioritise / ignore rules</h2>
<p class="fieldDescription">
Shape the work-discovery API (<code>/Plugins/JRay/Tasks/Pending</code>).
<strong>Ignore</strong> hides matching items from extraction workers;
<strong>Prioritise</strong> pushes them to the front of the queue. A more
specific rule wins (item&nbsp;&gt;&nbsp;series&nbsp;&gt;&nbsp;genre). Setting a
rule for a target that already has one replaces it, so nothing can be both
prioritised and ignored.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.5em;align-items:flex-end;margin-bottom:1em;">
<div class="selectContainer" style="margin:0;">
<label class="selectLabel" for="JRayRuleScope">Scope</label>
<select id="JRayRuleScope" is="emby-select">
<option value="Genre">Genre</option>
<option value="Series">Series</option>
<option value="Item">Item</option>
</select>
</div>
<div id="JRayItemSearchContainer" class="inputContainer" style="margin:0;flex:1;min-width:14em;display:none;">
<label class="inputLabel inputLabelUnfocused" for="JRayItemSearch">Search title</label>
<input id="JRayItemSearch" type="text" is="emby-input" placeholder="Type a movie or episode name…" />
</div>
<div class="selectContainer" style="margin:0;flex:1;min-width:14em;">
<label class="selectLabel" for="JRayRuleValue">Target</label>
<select id="JRayRuleValue" is="emby-select"></select>
</div>
<div class="selectContainer" style="margin:0;">
<label class="selectLabel" for="JRayRuleAction">Action</label>
<select id="JRayRuleAction" is="emby-select">
<option value="Ignore">Ignore</option>
<option value="Prioritise">Prioritise</option>
</select>
</div>
<button id="JRayAddRule" is="emby-button" type="button" class="raised emby-button">
<span>Add rule</span>
</button>
</div>
<div id="JRayRulesList"></div>
</div>
</div>
<script type="text/javascript">
@@ -46,6 +112,256 @@
pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223'
};
function jrayUrl(path) {
return ApiClient.getUrl('Plugins/JRay/' + path);
}
function jrayApi(path, method, body) {
var opts = {
url: jrayUrl(path),
type: method || 'GET'
};
if (body !== undefined) {
opts.data = JSON.stringify(body);
opts.contentType = 'application/json';
}
if (!method || method === 'GET') {
opts.dataType = 'json';
}
return ApiClient.ajax(opts);
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---- Dependency status ----
// Jellyfin cannot install a plugin's dependency, so the only thing
// that closes the gap is saying so where an admin can act on it.
function loadDependencyStatus() {
var el = document.querySelector('#JRayDependencyStatus');
return jrayApi('Status/Dependencies').then(function (status) {
el.style.display = '';
if (status.file_transformation_available) {
el.style.background = 'rgba(82,168,82,0.15)';
el.innerHTML = '<strong>File Transformation detected.</strong> '
+ 'The pause overlay is served by rewriting index.html as it is '
+ 'sent, leaving the file on disk untouched.';
return;
}
el.style.background = 'rgba(220,160,60,0.18)';
el.innerHTML = '<strong>File Transformation is not installed &mdash; the pause '
+ 'overlay is disabled.</strong> Every other JRay feature is unaffected. '
+ 'To enable it, add this repository in Dashboard &rarr; Plugins &rarr; '
+ 'Repositories, then install "File Transformation" and restart:<br />'
+ '<code>' + escapeHtml(status.file_transformation_manifest_url) + '</code>';
}).catch(function () {
el.style.display = 'none';
});
}
// ---- Coverage ----
var JRayCoverColors = {
covered: '#4caf50',
prioritised: '#ff9800',
pending: '#9e9e9e',
ignored: 'rgba(127,127,127,0.35)'
};
function renderBar(el, counts) {
el.innerHTML = '';
var order = [
['covered', counts.covered],
['prioritised', counts.prioritised],
['pending', counts.pending - counts.prioritised],
['ignored', counts.ignored]
];
order.forEach(function (seg) {
if (counts.total <= 0 || seg[1] <= 0) { return; }
var d = document.createElement('div');
d.style.background = JRayCoverColors[seg[0]];
d.style.width = (100 * seg[1] / counts.total) + '%';
el.appendChild(d);
});
}
function pct(counts) {
var denom = counts.total - counts.ignored;
if (denom <= 0) { return '—'; }
return Math.round(100 * counts.covered / denom) + '%';
}
function renderBreakdown(el, rows) {
if (!rows || !rows.length) {
el.textContent = 'Nothing to show.';
return;
}
var html = '<table style="width:100%;border-collapse:collapse;">' +
'<thead><tr style="text-align:left;">' +
'<th>Name</th><th>Covered</th><th>Pending</th><th>Ignored</th><th>Total</th><th>%</th>' +
'</tr></thead><tbody>';
rows.forEach(function (r) {
var c = r.counts;
html += '<tr>' +
'<td>' + escapeHtml(r.label) + '</td>' +
'<td>' + c.covered + '</td>' +
'<td>' + c.pending + (c.prioritised ? ' (' + c.prioritised + ' ★)' : '') + '</td>' +
'<td>' + c.ignored + '</td>' +
'<td>' + c.total + '</td>' +
'<td>' + pct(c) + '</td>' +
'</tr>';
});
html += '</tbody></table>';
el.innerHTML = html;
}
function loadCoverage() {
document.querySelector('#JRayCoverageSummary').textContent = 'Loading…';
jrayApi('Coverage').then(function (report) {
var t = report.total;
document.querySelector('#JRayCoverageSummary').innerHTML =
'<strong>' + pct(t) + '</strong> covered — ' +
t.covered + ' of ' + (t.total - t.ignored) + ' items processed' +
(t.ignored ? ' (' + t.ignored + ' ignored)' : '') + '.';
renderBar(document.querySelector('#JRayCoverageBar'), t);
document.querySelector('#JRayCoverageLegend').innerHTML =
'<span style="color:' + JRayCoverColors.covered + '">■</span> covered &nbsp; ' +
'<span style="color:' + JRayCoverColors.prioritised + '">■</span> prioritised &nbsp; ' +
'<span style="color:' + JRayCoverColors.pending + '">■</span> pending &nbsp; ' +
'<span>▨</span> ignored';
renderBreakdown(document.querySelector('#JRayCoverageByType'), report.by_media_type);
renderBreakdown(document.querySelector('#JRayCoverageByGenre'), report.by_genre);
});
}
// ---- Rules ----
var JRayOptionCache = { Genre: null, Series: null };
function loadOptionsFor(scope) {
if (JRayOptionCache[scope]) {
return Promise.resolve(JRayOptionCache[scope]);
}
var path = scope === 'Series' ? 'Coverage/Series' : 'Coverage/Genres';
return jrayApi(path).then(function (opts) {
JRayOptionCache[scope] = opts;
return opts;
});
}
function fillSelect(select, opts, emptyText) {
if (!opts.length) {
select.innerHTML = '<option value="">' + emptyText + '</option>';
return;
}
select.innerHTML = opts.map(function (o) {
return '<option value="' + escapeHtml(o.value) + '">' + escapeHtml(o.label) + '</option>';
}).join('');
}
function populateValueSelect() {
var scope = document.querySelector('#JRayRuleScope').value;
var select = document.querySelector('#JRayRuleValue');
var isItem = scope === 'Item';
document.querySelector('#JRayItemSearchContainer').style.display = isItem ? '' : 'none';
if (isItem) {
// The target dropdown is filled from a live search instead of a full list.
select.innerHTML = '<option value="">Type a title to search…</option>';
return;
}
select.innerHTML = '<option value="">Loading…</option>';
loadOptionsFor(scope).then(function (opts) {
fillSelect(select, opts, '(none found)');
});
}
var JRayItemSearchTimer = null;
function onItemSearch() {
var term = document.querySelector('#JRayItemSearch').value.trim();
var select = document.querySelector('#JRayRuleValue');
clearTimeout(JRayItemSearchTimer);
if (!term) {
select.innerHTML = '<option value="">Type a title to search…</option>';
return;
}
select.innerHTML = '<option value="">Searching…</option>';
JRayItemSearchTimer = setTimeout(function () {
jrayApi('Coverage/Items?search=' + encodeURIComponent(term)).then(function (opts) {
fillSelect(select, opts, '(no matches)');
});
}, 300);
}
function labelForRule(rule) {
if (rule.label) { return rule.label; }
var cache = JRayOptionCache[rule.scope];
if (cache) {
var hit = cache.filter(function (o) { return o.value === rule.value; })[0];
if (hit) { return hit.label; }
}
return rule.value;
}
function loadRules() {
jrayApi('Policy/Rules').then(function (rules) {
var el = document.querySelector('#JRayRulesList');
if (!rules.length) {
el.innerHTML = '<p class="fieldDescription">No rules yet.</p>';
return;
}
var html = '<table style="width:100%;border-collapse:collapse;">' +
'<thead><tr style="text-align:left;"><th>Action</th><th>Scope</th><th>Target</th><th></th></tr></thead><tbody>';
rules.forEach(function (r) {
var badge = r.action === 'Prioritise'
? '<span style="color:' + JRayCoverColors.prioritised + '">★ Prioritise</span>'
: '<span>⦸ Ignore</span>';
html += '<tr>' +
'<td>' + badge + '</td>' +
'<td>' + escapeHtml(r.scope) + '</td>' +
'<td>' + escapeHtml(labelForRule(r)) + '</td>' +
'<td><button is="emby-button" type="button" class="emby-button jray-del" ' +
'data-scope="' + escapeHtml(r.scope) + '" data-value="' + escapeHtml(r.value) + '">Remove</button></td>' +
'</tr>';
});
html += '</tbody></table>';
el.innerHTML = html;
Array.prototype.forEach.call(el.querySelectorAll('.jray-del'), function (btn) {
btn.addEventListener('click', function () {
var q = 'Policy/Rules?scope=' + encodeURIComponent(btn.getAttribute('data-scope')) +
'&value=' + encodeURIComponent(btn.getAttribute('data-value'));
jrayApi(q, 'DELETE').then(function () {
loadRules();
loadCoverage();
});
});
});
});
}
function addRule() {
var scope = document.querySelector('#JRayRuleScope').value;
var valueSelect = document.querySelector('#JRayRuleValue');
var value = valueSelect.value;
if (!value) { return; }
var rule = {
scope: scope,
value: value,
action: document.querySelector('#JRayRuleAction').value,
label: valueSelect.options[valueSelect.selectedIndex].text
};
jrayApi('Policy/Rules', 'PUT', rule).then(function () {
loadRules();
loadCoverage();
});
}
document.querySelector('#JRayConfigPage')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
@@ -55,8 +371,17 @@
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
Dashboard.hideLoadingMsg();
});
loadDependencyStatus();
populateValueSelect();
loadRules();
loadCoverage();
});
document.querySelector('#JRayRuleScope').addEventListener('change', populateValueSelect);
document.querySelector('#JRayItemSearch').addEventListener('input', onItemSearch);
document.querySelector('#JRayAddRule').addEventListener('click', addRule);
document.querySelector('#JRayConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
@@ -1,8 +1,8 @@
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -11,12 +11,19 @@ using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Exposes scene-actor-extraction "truth" data: which actors are on screen
/// at a given timestamp in a movie.
/// Exposes scene-actor-extraction "truth" data: which actors are present in
/// the scene at a given timestamp.
/// </summary>
/// <remarks>
/// Presence is <b>scene-scoped</b>, not instantaneous: a window is a claim about
/// scene membership, not a recognition event, so an actor who is off-camera
/// during a reverse shot is still present. Windows are served exactly as stored
/// — never merged, split or trimmed.
/// </remarks>
[ApiController]
[Route("Plugins/JRay/Items/{itemId}")]
[Authorize]
// TRACES: JR-004, JR-005, JR-010, JR-012, JR-013, JR-014 | SR-002
public class ActorsController : ControllerBase
{
private readonly ITruthDataService _truthDataService;
@@ -31,7 +38,7 @@ public class ActorsController : ControllerBase
}
/// <summary>
/// Gets the full actor timeline (every actor with their on-screen scene windows) for a movie.
/// Gets the full actor timeline (every actor with their scene-presence windows) for a movie.
/// </summary>
/// <param name="itemId">The Jellyfin item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
@@ -51,7 +58,26 @@ public class ActorsController : ControllerBase
}
/// <summary>
/// Gets the JRay context (currently: on-screen actors) at a given timestamp.
/// Gets how this item's truth data was obtained.
/// </summary>
/// <remarks>
/// Separate from <c>Timeline</c> on purpose: provenance is metadata *about*
/// the claim, and folding it into the truth file would mean the bytes served
/// back are not the bytes the producer wrote (JR-004).
/// </remarks>
/// <param name="itemId">The Jellyfin item id.</param>
/// <returns>The provenance, or 404 if no truth data exists for this item.</returns>
[HttpGet("Provenance")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
public ActionResult<TruthProvenance> GetProvenance(Guid itemId)
{
var provenance = _truthDataService.GetProvenance(itemId);
return provenance is null ? NotFound() : Ok(provenance);
}
/// <summary>
/// Gets the JRay context (currently: the actors in the scene) at a given timestamp.
/// This is an extensible envelope — future fields (locations, trivia, etc.)
/// will be added here without changing the route.
/// </summary>
@@ -71,9 +97,9 @@ public class ActorsController : ControllerBase
}
var context = new JRayContext();
foreach (var actor in truth.Actors.Where(actor => actor.Scenes.Any(scene => scene.Length == 2 && scene[0] <= t && t <= scene[1])))
foreach (var actor in PresenceLookup.ActorsPresentAt(truth, t))
{
context.Actors.Add(new ActorAtTime
context.Actors.Add(new ActorInScene
{
Name = actor.Name,
ImdbId = actor.ImdbId,
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Reports how much of the library has truth data (overall, by media type, and
/// by genre), and supplies the genre/series option lists the config page's
/// rule editor needs.
/// </summary>
/// <remarks>
/// Percent done is <c>covered / (total - ignored)</c>: ignored items are
/// intentionally out of scope, so excluding a genre must not drag the figure
/// down as though it were outstanding work.
/// </remarks>
[ApiController]
[Route("Plugins/JRay/Coverage")]
[Authorize(Roles = "Administrator")]
// TRACES: JR-018, JR-019 | PR-003
public class CoverageController : ControllerBase
{
private readonly ILibraryManager _libraryManager;
private readonly ITruthDataService _truthDataService;
private readonly IMediaPolicyStore _policyStore;
/// <summary>
/// Initializes a new instance of the <see cref="CoverageController"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="truthDataService">The truth data service.</param>
/// <param name="policyStore">The media policy store.</param>
public CoverageController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
{
_libraryManager = libraryManager;
_truthDataService = truthDataService;
_policyStore = policyStore;
}
/// <summary>
/// Gets the coverage report: totals plus breakdowns by media type and by genre.
/// </summary>
/// <returns>The coverage report.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<CoverageReport> GetCoverage()
{
var rules = _policyStore.GetRules();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
IsVirtualItem = false,
Recursive = true,
});
var report = new CoverageReport();
var byMediaType = new Dictionary<string, CoverageCounts>(StringComparer.Ordinal);
var byGenre = new Dictionary<string, CoverageCounts>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
{
if (!TasksController.MediaFileExists(item))
{
continue;
}
var covered = _truthDataService.HasTruth(item.Id, item.Path);
var action = PolicyResolver.Resolve(rules, item.Id, TasksController.GetSeriesId(item), item.Genres);
Accumulate(report.Total, covered, action);
Accumulate(GetBucket(byMediaType, MediaTypeLabel(item)), covered, action);
foreach (var genre in item.Genres)
{
if (!string.IsNullOrWhiteSpace(genre))
{
Accumulate(GetBucket(byGenre, genre), covered, action);
}
}
}
foreach (var kvp in byMediaType.OrderBy(k => k.Key, StringComparer.Ordinal))
{
report.ByMediaType.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value });
}
foreach (var kvp in byGenre.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
report.ByGenre.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value });
}
return Ok(report);
}
/// <summary>
/// Gets the distinct genres present on movies/episodes, for the rule editor.
/// </summary>
/// <returns>Genre options, sorted by name.</returns>
[HttpGet("Genres")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PickerOption>> GetGenres()
{
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
IsVirtualItem = false,
Recursive = true,
});
var genres = items
.SelectMany(item => item.Genres)
.Where(g => !string.IsNullOrWhiteSpace(g))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(g => g, StringComparer.OrdinalIgnoreCase)
.Select(g => new PickerOption { Value = g, Label = g });
return Ok(genres);
}
/// <summary>
/// Gets the series in the library, for the rule editor's series picker.
/// </summary>
/// <returns>Series options (id + title), sorted by title.</returns>
[HttpGet("Series")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PickerOption>> GetSeries()
{
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series },
IsVirtualItem = false,
Recursive = true,
});
var options = series
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(s => new PickerOption { Value = s.Id.ToString("D"), Label = s.Name });
return Ok(options);
}
/// <summary>
/// Searches movies/episodes by name, for the rule editor's item picker.
/// </summary>
/// <param name="search">A name fragment to match (case-insensitive). Required.</param>
/// <param name="limit">The maximum number of results (default 25, max 100).</param>
/// <returns>Matching item options (id + name), sorted by name.</returns>
[HttpGet("Items")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PickerOption>> SearchItems([FromQuery] string? search, [FromQuery] int limit = 25)
{
if (string.IsNullOrWhiteSpace(search))
{
return Ok(Array.Empty<PickerOption>());
}
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
IsVirtualItem = false,
Recursive = true,
SearchTerm = search,
Limit = Math.Clamp(limit, 1, 100),
});
var options = items
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.Select(i => new PickerOption { Value = i.Id.ToString("D"), Label = ItemLabel(i) });
return Ok(options);
}
private static string ItemLabel(BaseItem item)
{
// Give episodes a series-qualified label so identically-named episodes
// (e.g. "Pilot") are distinguishable in the picker.
if (item is Episode episode && !string.IsNullOrEmpty(episode.SeriesName))
{
return episode.SeriesName + " — " + item.Name;
}
return item.Name;
}
private static void Accumulate(CoverageCounts counts, bool covered, PolicyAction? action)
{
counts.Total++;
if (action == PolicyAction.Ignore)
{
counts.Ignored++;
return;
}
if (covered)
{
counts.Covered++;
return;
}
counts.Pending++;
if (action == PolicyAction.Prioritise)
{
counts.Prioritised++;
}
}
private static CoverageCounts GetBucket(Dictionary<string, CoverageCounts> buckets, string key)
{
if (!buckets.TryGetValue(key, out var counts))
{
counts = new CoverageCounts();
buckets[key] = counts;
}
return counts;
}
private static string MediaTypeLabel(BaseItem item)
{
return item.GetBaseItemKind() == BaseItemKind.Episode ? "TV" : "Film";
}
}
@@ -0,0 +1,222 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Fetches actor-timeline manifests from the configured public servers.
/// </summary>
// TRACES: JR-025, JR-031 | PR-006
[ApiController]
[Authorize(Policy = Policies.RequiresElevation)]
[Route("Plugins/JRay")]
[Produces("application/json")]
public class ManifestController : ControllerBase
{
private readonly ILibraryManager _libraryManager;
private readonly IManifestExchangeClient _exchange;
private readonly IManagedTruthStore _truthStore;
private readonly ManifestAligner _aligner;
private readonly ILogger<ManifestController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ManifestController"/> class.
/// </summary>
/// <param name="libraryManager">Library manager.</param>
/// <param name="exchange">Manifest exchange client.</param>
/// <param name="truthStore">Managed truth store.</param>
/// <param name="aligner">Aligns a fetched manifest to the local file.</param>
/// <param name="logger">Logger.</param>
public ManifestController(
ILibraryManager libraryManager,
IManifestExchangeClient exchange,
IManagedTruthStore truthStore,
ManifestAligner aligner,
ILogger<ManifestController> logger)
{
_libraryManager = libraryManager;
_exchange = exchange;
_truthStore = truthStore;
_aligner = aligner;
_logger = logger;
}
/// <summary>
/// Resolves an item across the configured servers and stores the first
/// acceptable manifest.
/// </summary>
/// <param name="itemId">The library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>What was fetched, and from where.</returns>
/// <response code="200">A manifest was stored.</response>
/// <response code="404">The item does not exist, or no server had a manifest for it.</response>
/// <response code="409">Manifest sharing is disabled in the plugin configuration.</response>
[HttpPost("Items/{itemId}/Fetch")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManifestFetchResult>> FetchItem(
[FromRoute] Guid itemId,
CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.EnableManifestSharing)
{
// Off by default and opt-in: this is a network egress feature, so it
// never runs merely because an endpoint was called.
return Conflict(new { error = "manifest sharing is disabled" });
}
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
return NotFound();
}
var query = BuildQuery(item);
if (query is null)
{
return NotFound(new { error = "item has no TMDB or IMDB id to look up" });
}
var servers = config.Servers.ToList();
var outcome = item is Episode
? await _exchange.FetchEpisodeAsync(servers, config.MinimumMatchTier, query, cancellationToken)
.ConfigureAwait(false)
: await _exchange.FetchMovieAsync(servers, config.MinimumMatchTier, query, cancellationToken)
.ConfigureAwait(false);
if (outcome?.Manifest is null)
{
return NotFound(new { error = "no configured server had an acceptable manifest" });
}
// Align before storing. The server has never seen this file, so where a
// local audio alignment is possible it supersedes the offset the server
// sent — and it needs no round trip, so no signature leaves the
// instance (JR-047).
var alignment = await _aligner.AlignAsync(
item.Path ?? string.Empty,
item.RunTimeTicks is { } ticks ? TimeSpan.FromTicks(ticks).TotalSeconds : 0.0,
outcome.Manifest,
outcome.Tier,
outcome.OffsetSec,
config.ComputeAudioSignatures,
cancellationToken)
.ConfigureAwait(false);
var caveat = ManifestConverter.DescribeCaveat(alignment.Tier, alignment.OffsetSec, alignment);
// The offset is applied here, once, so the stored truth is always in the
// local file's own timebase and no reader needs offset awareness.
var truth = ManifestConverter.ToTruthFile(outcome.Manifest, alignment.OffsetSec, item.Path ?? string.Empty);
var provenance = new TruthProvenance
{
Source = TruthSource.Fetched,
ServerUrl = outcome.ServerUrl,
MatchTier = alignment.Tier,
OffsetSec = alignment.OffsetSec,
Alignment = alignment,
Caveat = caveat,
RecordedAt = DateTime.UtcNow,
};
await _truthStore.SaveAsync(itemId, truth, provenance, cancellationToken).ConfigureAwait(false);
_logger.LogInformation(
"Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s, aligned by {Source})",
itemId,
outcome.ServerUrl,
alignment.Tier,
alignment.OffsetSec,
alignment.Source);
return Ok(new ManifestFetchResult
{
ServerUrl = outcome.ServerUrl,
Match = alignment.Tier.ToString().ToLowerInvariant(),
OffsetSec = alignment.OffsetSec,
ActorCount = truth.Actors.Count,
Caveat = caveat,
});
}
/// <summary>
/// Per-server reachability and last error, for the configuration page.
/// </summary>
/// <returns>One entry per configured server, in configured order.</returns>
/// <response code="200">Status for each configured server.</response>
[HttpGet("Servers/Status")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<ServerStatus>> GetServerStatus()
{
var config = Plugin.Instance?.Configuration;
if (config is null)
{
return Ok(Array.Empty<ServerStatus>());
}
return Ok(_exchange.GetStatus(config.Servers.ToList()));
}
/// <summary>
/// Reads a provider id from an item, or null when it is absent.
/// </summary>
private static string? ProviderId(BaseItem? item, string provider) =>
item?.ProviderIds is { } ids && ids.TryGetValue(provider, out var value)
&& !string.IsNullOrWhiteSpace(value)
? value
: null;
/// <summary>
/// Builds the lookup query from an item's provider ids and measured runtime.
/// </summary>
private static TitleQuery? BuildQuery(BaseItem item)
{
var runtimeSec = item.RunTimeTicks.HasValue
? TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalSeconds
: (double?)null;
if (item is Episode episode)
{
var series = episode.Series;
var seriesTmdb = ProviderId(series, "Tmdb");
if (string.IsNullOrEmpty(seriesTmdb))
{
return null;
}
return new TitleQuery
{
SeriesTmdbId = seriesTmdb,
Season = episode.ParentIndexNumber,
Episode = episode.IndexNumber,
RuntimeSec = runtimeSec,
};
}
var tmdb = ProviderId(item, "Tmdb");
var imdb = ProviderId(item, "Imdb");
if (string.IsNullOrEmpty(tmdb) && string.IsNullOrEmpty(imdb))
{
return null;
}
return new TitleQuery { TmdbId = tmdb, ImdbId = imdb, RuntimeSec = runtimeSec };
}
}
@@ -0,0 +1,82 @@
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Manages the prioritise/ignore rules that shape the work-discovery API
/// (<c>GET /Plugins/JRay/Tasks/Pending</c>). Rules can target a genre, a
/// series, or a single item; setting a rule for a target that already has one
/// replaces it, so a target can never be both prioritised and ignored.
/// </summary>
/// <remarks>
/// Rules steer work discovery only. They never reach the read endpoints or the
/// overlay, because a rule says "don't spend compute here", not "pretend this
/// item does not exist".
/// </remarks>
[ApiController]
[Route("Plugins/JRay/Policy")]
[Authorize(Roles = "Administrator")]
// TRACES: JR-016, JR-014 | PR-003
public class PolicyController : ControllerBase
{
private readonly IMediaPolicyStore _policyStore;
/// <summary>
/// Initializes a new instance of the <see cref="PolicyController"/> class.
/// </summary>
/// <param name="policyStore">The media policy store.</param>
public PolicyController(IMediaPolicyStore policyStore)
{
_policyStore = policyStore;
}
/// <summary>
/// Gets all configured prioritise/ignore rules.
/// </summary>
/// <returns>The current rules.</returns>
[HttpGet("Rules")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<MediaPolicyRule>> GetRules()
{
return Ok(_policyStore.GetRules());
}
/// <summary>
/// Adds or replaces a rule. If a rule already exists for the same scope
/// and value, its action is updated.
/// </summary>
/// <param name="rule">The rule to set.</param>
/// <returns>204 on success, or 400 if the rule has no value.</returns>
[HttpPut("Rules")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult SetRule([FromBody] MediaPolicyRule rule)
{
if (rule is null || string.IsNullOrWhiteSpace(rule.Value))
{
return BadRequest("A rule must have a non-empty value.");
}
_policyStore.SetRule(rule);
return NoContent();
}
/// <summary>
/// Removes the rule matching the given scope and value.
/// </summary>
/// <param name="scope">The scope of the rule to remove.</param>
/// <param name="value">The value of the rule to remove.</param>
/// <returns>204 whether or not a matching rule existed.</returns>
[HttpDelete("Rules")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult RemoveRule([FromQuery] PolicyScope scope, [FromQuery] string value)
{
_policyStore.RemoveRule(scope, value ?? string.Empty);
return NoContent();
}
}
@@ -0,0 +1,38 @@
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Reports whether JRay's hard dependency on the File Transformation plugin is
/// satisfied, for the configuration page to surface.
/// </summary>
/// <remarks>
/// Absent that plugin the pause overlay is disabled and every other JRay feature
/// continues to work — so this is a status to display, not an error to raise.
/// </remarks>
[ApiController]
[Route("Plugins/JRay/Status")]
[Authorize(Roles = "Administrator")]
// TRACES: JR-023 | PR-004
public class StatusController : ControllerBase
{
/// <summary>
/// Gets the dependency status.
/// </summary>
/// <returns>The dependency status.</returns>
[HttpGet("Dependencies")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<DependencyStatus> GetDependencies()
{
return Ok(new DependencyStatus
{
FileTransformationAvailable = Plugin.FileTransformationAvailable,
OverlayEnabled = Plugin.OverlayEnabled,
FileTransformationManifestUrl = FileTransformationRegistration.ManifestUrl,
});
}
}
@@ -1,10 +1,13 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -16,9 +19,19 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// Lets a remote extraction worker discover which library items still need
/// to be processed.
/// </summary>
/// <remarks>
/// The sample is random so that repeated polling spreads work across the
/// backlog without the server tracking who holds what, and so two workers
/// polling concurrently mostly do not collide.
///
/// Prioritise/ignore rules are applied <b>here and only here</b> (JR-017):
/// they express "don't spend compute on this", not "pretend this does not
/// exist", so they never reach the read endpoints or the overlay.
/// </remarks>
[ApiController]
[Route("Plugins/JRay/Tasks")]
[Authorize(Roles = "Administrator")]
// TRACES: JR-015, JR-017 | PR-003
public class TasksController : ControllerBase
{
private const int DefaultLimit = 10;
@@ -26,16 +39,19 @@ public class TasksController : ControllerBase
private readonly ILibraryManager _libraryManager;
private readonly ITruthDataService _truthDataService;
private readonly IMediaPolicyStore _policyStore;
/// <summary>
/// Initializes a new instance of the <see cref="TasksController"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="truthDataService">The truth data service.</param>
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService)
/// <param name="policyStore">The prioritise/ignore policy store.</param>
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
{
_libraryManager = libraryManager;
_truthDataService = truthDataService;
_policyStore = policyStore;
}
/// <summary>
@@ -48,6 +64,7 @@ public class TasksController : ControllerBase
public ActionResult<IEnumerable<PendingExtractionItem>> GetPending([FromQuery] int limit = DefaultLimit)
{
var effectiveLimit = Math.Clamp(limit, 1, MaxLimit);
var rules = _policyStore.GetRules();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
@@ -56,17 +73,69 @@ public class TasksController : ControllerBase
Recursive = true,
});
// Keep only items that still need truth data, then apply the policy:
// drop anything ignored, and order prioritised items ahead of the rest.
// Randomise within each tier so the backlog still spreads across workers.
var pending = items
.Where(item => !string.IsNullOrEmpty(item.Path) && !_truthDataService.HasTruth(item.Id, item.Path))
.OrderBy(_ => Random.Shared.Next())
.Take(effectiveLimit)
.Select(item => new PendingExtractionItem
.Where(item => MediaFileExists(item) && !_truthDataService.HasTruth(item.Id, item.Path))
.Select(item => new
{
ItemId = item.Id,
Path = item.Path,
Name = item.Name
Item = item,
Action = PolicyResolver.Resolve(rules, item.Id, GetSeriesId(item), item.Genres)
})
.Where(x => x.Action != PolicyAction.Ignore)
.OrderByDescending(x => x.Action == PolicyAction.Prioritise)
.ThenBy(_ => Random.Shared.Next())
.Take(effectiveLimit)
.Select(x => new PendingExtractionItem
{
ItemId = x.Item.Id,
Path = x.Item.Path,
Name = x.Item.Name
});
return Ok(pending);
}
/// <summary>
/// Gets the series id for an episode, or <see cref="Guid.Empty"/> for any
/// other item type (so series rules only ever match episodes).
/// </summary>
/// <param name="item">The library item.</param>
/// <returns>The owning series id, or empty.</returns>
internal static Guid GetSeriesId(BaseItem item)
{
return item is Episode episode ? episode.SeriesId : Guid.Empty;
}
/// <summary>
/// Determines whether an item's backing media still exists on disk.
/// <para>
/// A library item can outlive its file: deleting media from disk does not
/// always purge the Jellyfin database entry, and such ghosts keep
/// <c>IsVirtualItem == false</c>, so filtering on that flag alone is not
/// enough. Without this check the pending endpoint hands stale paths/URLs to
/// extraction workers, and — because a missing file can never gain truth
/// data — those items stay pending forever.
/// </para>
/// Only local (file-protocol) items are stat-checked; remote/streamed items
/// are assumed present so we never stat something off-box.
/// </summary>
/// <param name="item">The library item.</param>
/// <returns><c>true</c> if the item has usable backing media; otherwise <c>false</c>.</returns>
internal static bool MediaFileExists(BaseItem item)
{
if (string.IsNullOrEmpty(item.Path))
{
return false;
}
if (!item.IsFileProtocol)
{
return true;
}
// Path may point at a file or, for folder-based media, a directory.
return System.IO.File.Exists(item.Path) || Directory.Exists(item.Path);
}
}
@@ -2,6 +2,7 @@ using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
@@ -12,15 +13,21 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Accepts scene-actor-extraction "truth" data pushed directly by a remote
/// extraction worker, for servers that cannot run the extraction pipeline
/// locally. See SPEC.md.
/// locally. See SPEC.md §2.
/// </summary>
/// <remarks>
/// The <c>schema_version</c> check refuses an unrecognised version rather than
/// guessing at its shape. It defers to <see cref="TruthSchema"/> rather than
/// holding its own constant: this used to be the only source that checked while
/// sidecar reads did not, so the version the plugin claimed to require and the
/// one it would actually parse could drift apart.
/// </remarks>
[ApiController]
[Route("Plugins/JRay/Items/{itemId}/Truth")]
[Authorize(Roles = "Administrator")]
// TRACES: JR-003, JR-009, JR-014 | SR-003
public class TruthController : ControllerBase
{
private const int SupportedSchemaVersion = 1;
private readonly IManagedTruthStore _managedTruthStore;
private readonly ITruthDataService _truthDataService;
@@ -47,12 +54,13 @@ public class TruthController : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> PutTruth(Guid itemId, [FromBody] TruthFile truth, CancellationToken cancellationToken)
{
if (truth.SchemaVersion != SupportedSchemaVersion)
if (!TruthSchema.IsSupported(truth))
{
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
return BadRequest(TruthSchema.DescribeRejection(truth.SchemaVersion));
}
await _managedTruthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false);
var provenance = TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow);
await _managedTruthStore.SaveAsync(itemId, truth, provenance, cancellationToken).ConfigureAwait(false);
_truthDataService.Invalidate(itemId);
return NoContent();
@@ -11,9 +11,14 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// Serves static client-side assets for JRay, e.g. the pause-overlay script
/// injected into the web client.
/// </summary>
/// <remarks>
/// Anonymous by necessity, not by oversight: the script tag is injected into
/// <c>index.html</c>, which is served before a user has logged in.
/// </remarks>
[ApiController]
[Route("Plugins/JRay")]
[AllowAnonymous]
// TRACES: JR-014, JR-020 | PR-001
public class WebController : ControllerBase
{
private const string OverlayScriptResource = "Jellyfin.Plugin.JRay.Web.jray-overlay.js";
@@ -11,10 +11,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.9.11" >
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" >
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.9.11">
<PackageReference Include="Jellyfin.Model" Version="10.11.5">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
@@ -25,6 +25,15 @@
<PackageReference Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" PrivateAssets="All" />
</ItemGroup>
<ItemGroup>
<!-- Lets the test project reach internals such as
WebClientPatchService.RemoveInjection, which is factored out precisely
so the removal logic is testable without touching a filesystem. -->
<AssemblyAttribute Include="System.Runtime.CompilerServices.InternalsVisibleToAttribute">
<_Parameter1>Jellyfin.Plugin.JRay.Tests</_Parameter1>
</AssemblyAttribute>
</ItemGroup>
<ItemGroup>
<None Remove="Configuration\configPage.html" />
<EmbeddedResource Include="Configuration\configPage.html" />
@@ -3,9 +3,17 @@ using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// An actor visible on screen at a queried timestamp.
/// An actor present in the scene at a queried timestamp.
/// </summary>
public class ActorAtTime
/// <remarks>
/// "Present in the scene", not "visible on screen". The truth file makes a claim
/// about scene membership, so an actor who has turned away or is off-camera
/// during a reverse shot is still present. The type was named
/// <c>ActorAtTime</c>, which invited exactly the instantaneous reading SR-002
/// forbids.
/// </remarks>
// TRACES: JR-005 | SR-002
public class ActorInScene
{
/// <summary>
/// Gets or sets the actor's display name.
@@ -0,0 +1,42 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Which comparison produced the offset that was applied to a fetched manifest.
/// </summary>
/// <remarks>
/// Recorded because the two are not equally strong evidence. The server has
/// never seen the local file, so its offset is at best a runtime-difference
/// inference; a local alignment compares the manifest's own audio signature
/// against the file the windows will actually be drawn over.
/// </remarks>
public enum AlignmentSource
{
/// <summary>
/// The server's offset was applied — no local alignment was possible.
/// </summary>
/// <remarks>
/// Either signatures are switched off, the item is under the 120 s window,
/// the manifest carried no signature, or the decode failed. A signature is
/// an enhancement, so every one of those degrades to this rather than
/// failing the fetch.
/// </remarks>
Server = 0,
/// <summary>
/// A local audio alignment was computed and its offset was applied.
/// </summary>
Local = 1,
/// <summary>
/// A local alignment was attempted and the two signatures did not match at
/// any tier; the server's offset was applied and the disagreement recorded.
/// </summary>
/// <remarks>
/// Deliberately not a failure. The audio may legitimately differ — a
/// different language track, a heavy re-encode — and a signature must never
/// be able to break a fetch. But it is the strongest available hint that a
/// manifest describes different content, so it is surfaced as a caveat
/// rather than discarded.
/// </remarks>
LocalMismatch = 2,
}
@@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One labelled row of a coverage breakdown (e.g. a single genre, or a single
/// media type such as "Film" or "TV").
/// </summary>
public class CoverageBreakdownRow
{
/// <summary>
/// Gets or sets the label for this row (genre name or media type name).
/// </summary>
[JsonPropertyName("label")]
public string Label { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the coverage counts for this row.
/// </summary>
[JsonPropertyName("counts")]
public CoverageCounts Counts { get; set; } = new();
}
@@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A count of library items bucketed by JRay coverage status. Used both for
/// the overall totals and for each per-genre / per-media-type breakdown row.
/// </summary>
public class CoverageCounts
{
/// <summary>
/// Gets or sets the total number of items in this bucket.
/// </summary>
[JsonPropertyName("total")]
public int Total { get; set; }
/// <summary>
/// Gets or sets the number of items that already have truth data.
/// </summary>
[JsonPropertyName("covered")]
public int Covered { get; set; }
/// <summary>
/// Gets or sets the number of items awaiting processing (no truth data,
/// not ignored). Includes prioritised-but-not-yet-covered items.
/// </summary>
[JsonPropertyName("pending")]
public int Pending { get; set; }
/// <summary>
/// Gets or sets the number of pending items that are prioritised.
/// </summary>
[JsonPropertyName("prioritised")]
public int Prioritised { get; set; }
/// <summary>
/// Gets or sets the number of items excluded from processing by an ignore rule.
/// </summary>
[JsonPropertyName("ignored")]
public int Ignored { get; set; }
}
@@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A snapshot of how much of the library has truth data, with overall totals
/// and breakdowns by media type and by genre.
/// </summary>
public class CoverageReport
{
/// <summary>
/// Gets or sets the coverage counts across the whole library.
/// </summary>
[JsonPropertyName("total")]
public CoverageCounts Total { get; set; } = new();
/// <summary>
/// Gets the per-media-type breakdown (Film vs TV).
/// </summary>
[JsonPropertyName("by_media_type")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<CoverageBreakdownRow> ByMediaType { get; } = new();
/// <summary>
/// Gets the per-genre breakdown, one row per genre present in the library.
/// </summary>
[JsonPropertyName("by_genre")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<CoverageBreakdownRow> ByGenre { get; } = new();
}
@@ -0,0 +1,37 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Whether JRay's one hard dependency is satisfied, and what to do if it is not.
/// </summary>
/// <remarks>
/// Jellyfin has no plugin dependency mechanism — a manifest cannot declare that
/// another plugin is required, and nothing will install one. The gap is closed by
/// telling the admin, on the page where they can act on it. A warning that exists
/// only in the server log is one nobody reads.
/// </remarks>
public class DependencyStatus
{
/// <summary>
/// Gets or sets a value indicating whether the File Transformation plugin was
/// found and JRay's overlay transformation registered with it.
/// </summary>
[JsonPropertyName("file_transformation_available")]
public bool FileTransformationAvailable { get; set; }
/// <summary>
/// Gets or sets a value indicating whether the overlay is switched on in
/// configuration. It is served only when this <i>and</i>
/// <see cref="FileTransformationAvailable"/> hold.
/// </summary>
[JsonPropertyName("overlay_enabled")]
public bool OverlayEnabled { get; set; }
/// <summary>
/// Gets or sets the repository manifest URL an admin adds to install the
/// missing dependency.
/// </summary>
[JsonPropertyName("file_transformation_manifest_url")]
public string FileTransformationManifestUrl { get; set; } = string.Empty;
}
+2 -2
View File
@@ -11,8 +11,8 @@ namespace Jellyfin.Plugin.JRay.Models;
public class JRayContext
{
/// <summary>
/// Gets the list of actors visible on screen at the queried timestamp.
/// Gets the list of actors visible in the scene at the queried timestamp.
/// </summary>
[JsonPropertyName("actors")]
public Collection<ActorAtTime> Actors { get; } = new();
public Collection<ActorInScene> Actors { get; } = new();
}
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One shareable actor timeline for one cut of one title, as the public server
/// serves it. See the server specification §2.
/// </summary>
/// <remarks>
/// This is the <b>exchange envelope</b>, versioned by <c>jmanifest_version</c>
/// and deliberately separate from the truth file's <c>schema_version</c>: a
/// change to how manifests are transported need not force a truth-file bump.
/// They currently coincide at 2 only because the SR-003 bump touched both.
/// <para>
/// A manifest is never trusted merely because a server served it (JR-027). Every
/// field below is re-validated on receipt against the same rules the server
/// applies on upload.
/// </para>
/// </remarks>
// TRACES: JR-025, JR-027 | SR-003
public class Jmanifest
{
/// <summary>Gets or sets the exchange envelope version this manifest speaks.</summary>
[JsonPropertyName("jmanifest_version")]
public int JmanifestVersion { get; set; }
/// <summary>Gets or sets what the work is — TMDB/IMDB ids and episode coordinates.</summary>
[JsonPropertyName("identity")]
public JmanifestIdentity? Identity { get; set; }
/// <summary>Gets or sets which encode the timings apply to.</summary>
[JsonPropertyName("cut")]
public JmanifestCut? Cut { get; set; }
/// <summary>Gets or sets extraction provenance.</summary>
[JsonPropertyName("extraction")]
public JmanifestExtraction? Extraction { get; set; }
/// <summary>Gets the actors and their presence windows.</summary>
[JsonPropertyName("actors")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<JmanifestActor> Actors { get; } = new();
}
@@ -0,0 +1,29 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>One actor's timeline within a manifest.</summary>
public class JmanifestActor
{
/// <summary>
/// Gets or sets the display name. Server-authoritative on download: the
/// server resolves each actor to a TMDB person and serves names from its own
/// table, so a name a contributor invented never round-trips.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>Gets or sets the IMDB person id.</summary>
[JsonPropertyName("imdb_id")]
public string? ImdbId { get; set; }
/// <summary>Gets or sets the TMDB person id — the primary join key.</summary>
[JsonPropertyName("tmdb_id")]
public string? TmdbId { get; set; }
/// <summary>Gets the presence windows.</summary>
[JsonPropertyName("scenes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<JmanifestScene> Scenes { get; } = new();
}
@@ -0,0 +1,32 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>Cut fingerprint (server spec §2, §3).</summary>
public class JmanifestCut
{
/// <summary>Gets or sets the decoded duration the timings came from.</summary>
[JsonPropertyName("runtime_sec")]
public double RuntimeSec { get; set; }
/// <summary>Gets or sets the container duration, if it differs.</summary>
[JsonPropertyName("container_duration_sec")]
public double? ContainerDurationSec { get; set; }
/// <summary>
/// Gets or sets the OpenSubtitles file hash, as a server may report it.
/// </summary>
/// <remarks>
/// Read-only in practice: the plugin never <em>sends</em> one. The file-hash
/// match tier is withdrawn on legal grounds — see
/// <see cref="Configuration.MatchTier"/> — because a file hash identifies the
/// exact release a user holds rather than the cut the timings describe.
/// </remarks>
[JsonPropertyName("video_hash")]
public string? VideoHash { get; set; }
/// <summary>Gets or sets the version-prefixed spectral-peak signature.</summary>
[JsonPropertyName("audio_signature")]
public string? AudioSignature { get; set; }
}
@@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>Extraction provenance (server spec §2).</summary>
public class JmanifestExtraction
{
/// <summary>Gets or sets the sampling rate used during extraction.</summary>
[JsonPropertyName("sample_fps")]
public double? SampleFps { get; set; }
/// <summary>
/// Gets or sets the re-acquisition timeout that shapes window extent.
/// Successor to the withdrawn <c>anneal_sec</c>.
/// </summary>
[JsonPropertyName("extinction_sec")]
public double? ExtinctionSec { get; set; }
/// <summary>Gets or sets the producing pipeline's version string.</summary>
[JsonPropertyName("pipeline_version")]
public string? PipelineVersion { get; set; }
/// <summary>Gets or sets how many references the gallery held.</summary>
[JsonPropertyName("gallery_size")]
public int? GallerySize { get; set; }
/// <summary>Gets or sets <c>global</c> or <c>limited</c>.</summary>
[JsonPropertyName("gallery_scope")]
public string? GalleryScope { get; set; }
}
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>Title identity (server spec §2).</summary>
public class JmanifestIdentity
{
/// <summary>Gets or sets <c>movie</c> or <c>episode</c>.</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
/// <summary>Gets or sets the TMDB id, for a movie.</summary>
[JsonPropertyName("tmdb_id")]
public string? TmdbId { get; set; }
/// <summary>Gets or sets the IMDB id, for a movie.</summary>
[JsonPropertyName("imdb_id")]
public string? ImdbId { get; set; }
/// <summary>Gets or sets the series TMDB id, for an episode.</summary>
[JsonPropertyName("series_tmdb_id")]
public string? SeriesTmdbId { get; set; }
/// <summary>Gets or sets the series IMDB id, for an episode.</summary>
[JsonPropertyName("series_imdb_id")]
public string? SeriesImdbId { get; set; }
/// <summary>Gets or sets the season number, for an episode.</summary>
[JsonPropertyName("season")]
public int? Season { get; set; }
/// <summary>Gets or sets the episode number, for an episode.</summary>
[JsonPropertyName("episode")]
public int? Episode { get; set; }
/// <summary>Gets or sets the display title.</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>Gets or sets the release year.</summary>
[JsonPropertyName("year")]
public int? Year { get; set; }
}
@@ -0,0 +1,38 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One presence window.
/// </summary>
/// <remarks>
/// <b>A window is a claim about scene membership, not a recognition event</b>
/// (SR-002). An actor who turns away, is occluded, or is off-camera while the
/// shot cuts to whoever they are speaking to is still present — so a consumer
/// must never read a window boundary as "the face was detected here", and must
/// not merge, split or trim windows.
/// </remarks>
public class JmanifestScene
{
/// <summary>Gets or sets the window start, in seconds.</summary>
[JsonPropertyName("start")]
public double Start { get; set; }
/// <summary>Gets or sets the window end, in seconds, inclusive.</summary>
[JsonPropertyName("end")]
public double End { get; set; }
/// <summary>
/// Gets or sets the accumulated posterior that justified this claim, in [0, 1].
/// </summary>
[JsonPropertyName("belief")]
public double? Belief { get; set; }
/// <summary>
/// Gets or sets how the actor was identified: <c>live</c>, <c>deferred</c>
/// or <c>pooled</c>.
/// </summary>
[JsonPropertyName("route")]
public string? Route { get; set; }
}
@@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>The server's reply to a manifest fetch (server spec §4).</summary>
public class ManifestFetchResponse
{
/// <summary>
/// Gets or sets the cut-match tier that was achieved: <c>exact</c>,
/// <c>audio</c>, <c>runtime</c> or <c>loose</c>.
/// </summary>
[JsonPropertyName("match")]
public string Match { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the offset, in seconds, the client must add to every window.
/// </summary>
/// <remarks>
/// Non-zero only for an <c>audio</c>-tier match, where the same cut was
/// found at a different trim. <b>The server returns the offset; the client
/// applies it</b> — manifests are never rewritten, so one stored manifest
/// serves every trim of the same cut.
/// </remarks>
[JsonPropertyName("offset_sec")]
public double OffsetSec { get; set; }
/// <summary>Gets or sets the manifest itself.</summary>
[JsonPropertyName("manifest")]
public Jmanifest? Manifest { get; set; }
}
@@ -0,0 +1,30 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// What a fetch stored, and from where.
/// </summary>
public class ManifestFetchResult
{
/// <summary>Gets or sets the server that supplied the manifest.</summary>
public string ServerUrl { get; set; } = string.Empty;
/// <summary>Gets or sets the cut-match tier achieved.</summary>
public string Match { get; set; } = string.Empty;
/// <summary>Gets or sets the offset applied to every window, in seconds.</summary>
public double OffsetSec { get; set; }
/// <summary>Gets or sets how many actors the stored truth file holds.</summary>
public int ActorCount { get; set; }
/// <summary>
/// Gets or sets a caveat to surface in the UI, or null when the match needs
/// no explanation.
/// </summary>
/// <remarks>
/// A <c>loose</c> match must surface as a caveat rather than being applied
/// silently — the runtimes differ by up to 30 seconds, which is usually a
/// different trim of the same cut but is not guaranteed to be.
/// </remarks>
public string? Caveat { get; set; }
}
@@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A single prioritise/ignore rule. A rule is uniquely identified by its
/// <see cref="Scope"/> and <see cref="Value"/>; setting a new action for an
/// existing scope+value replaces the previous one.
/// </summary>
public class MediaPolicyRule
{
/// <summary>
/// Gets or sets the scope this rule targets (genre, series, or item).
/// </summary>
[JsonPropertyName("scope")]
public PolicyScope Scope { get; set; }
/// <summary>
/// Gets or sets the target value: a genre name for <see cref="PolicyScope.Genre"/>,
/// a series id for <see cref="PolicyScope.Series"/>, or an item id for
/// <see cref="PolicyScope.Item"/>.
/// </summary>
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the action to apply (prioritise or ignore).
/// </summary>
[JsonPropertyName("action")]
public PolicyAction Action { get; set; }
/// <summary>
/// Gets or sets an optional human-readable label for the target (e.g. the
/// series or item display name), shown in the rules list. Informational only.
/// </summary>
[JsonPropertyName("label")]
public string Label { get; set; } = string.Empty;
}
@@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A selectable option for the config page's rule editor (a genre or a series),
/// pairing the value stored in a rule with a human-readable label.
/// </summary>
public class PickerOption
{
/// <summary>
/// Gets or sets the value stored in a <see cref="MediaPolicyRule"/>
/// (a genre name, or a series id).
/// </summary>
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the label shown to the user (genre name, or series title).
/// </summary>
[JsonPropertyName("label")]
public string Label { get; set; } = string.Empty;
}
@@ -0,0 +1,15 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// The action a <see cref="MediaPolicyRule"/> applies to matching items.
/// A given scope+value can hold at most one action at a time, so an item
/// can never be both prioritised and ignored by the same target.
/// </summary>
public enum PolicyAction
{
/// <summary>Push matching items to the front of the work queue.</summary>
Prioritise,
/// <summary>Exclude matching items from the work queue entirely.</summary>
Ignore
}
@@ -0,0 +1,18 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// The scope a <see cref="MediaPolicyRule"/> targets. More specific scopes
/// override broader ones when resolving an item's effective policy
/// (Item &gt; Series &gt; Genre).
/// </summary>
public enum PolicyScope
{
/// <summary>Matches every item that carries a given genre.</summary>
Genre,
/// <summary>Matches every episode of a given series (by series id).</summary>
Series,
/// <summary>Matches a single library item (by item id).</summary>
Item
}
@@ -0,0 +1,17 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>A series bundle (server spec §2).</summary>
public class SeriesBundle
{
/// <summary>Gets or sets the envelope version.</summary>
[JsonPropertyName("jmanifest_version")]
public int JmanifestVersion { get; set; }
/// <summary>Gets the episode manifests the server holds.</summary>
[JsonPropertyName("episodes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<Jmanifest> Episodes { get; } = new();
}
@@ -0,0 +1,23 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>What a server says it supports (server spec §9a).</summary>
public class ServerCapabilities
{
/// <summary>Gets or sets the server's own identity.</summary>
[JsonPropertyName("server_id")]
public string? ServerId { get; set; }
/// <summary>
/// Gets the exchange envelope versions the server accepts.
/// </summary>
/// <remarks>
/// Checked once rather than discovered as a rejection per manifest across a
/// whole library sweep.
/// </remarks>
[JsonPropertyName("jmanifest_versions")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<int> JmanifestVersions { get; } = new();
}
@@ -0,0 +1,22 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// The object handed to JRay's transformation callback by the File
/// Transformation plugin.
/// </summary>
/// <remarks>
/// That plugin builds a Newtonsoft <c>JObject</c> with a single <c>contents</c>
/// key and calls <c>JObject.ToObject(parameterType)</c> against this type.
/// Newtonsoft binds member names case-insensitively, so <see cref="Contents"/>
/// binds to <c>contents</c> without an attribute — and a System.Text.Json
/// attribute would have no effect here.
/// </remarks>
public class TransformationPayload
{
/// <summary>
/// Gets or sets the current contents of the file being served, including
/// any transformations applied by other plugins ahead of JRay in the
/// chain.
/// </summary>
public string? Contents { get; set; }
}
+19 -3
View File
@@ -5,8 +5,19 @@ namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One actor entry in a <see cref="TruthFile"/>, with the time windows during
/// which they are visible on screen.
/// which they are present in the scene.
/// </summary>
/// <remarks>
/// "Present in the scene", not "visible on screen": a window is a claim about
/// scene membership, so an actor who turns away or is off-camera during a
/// reverse shot is still present. Two windows mean a genuine departure and
/// return, not a break in detection.
///
/// Identity is public identifiers — never a name alone, which is ambiguous and
/// unstable. <c>jellyfin_id</c> is preferred locally and stripped on
/// contribution, being meaningless outside the instance that produced it.
/// </remarks>
// TRACES: JR-004, JR-007 | SR-001, SR-002
public class TruthActor
{
/// <summary>
@@ -34,8 +45,13 @@ public class TruthActor
public string JellyfinId { get; set; } = string.Empty;
/// <summary>
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
/// Gets the windows during which the actor is in the scene.
/// </summary>
/// <remarks>
/// Objects since <c>schema_version</c> 2, not the float pairs v1 used, so a
/// window can carry the belief and route behind the claim.
/// </remarks>
[JsonPropertyName("scenes")]
public Collection<double[]> Scenes { get; } = new();
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<TruthScene> Scenes { get; } = new();
}
@@ -0,0 +1,74 @@
using System.Text.Json.Serialization;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// How a fetched manifest was aligned to the local file, recorded alongside the
/// truth data.
/// </summary>
/// <remarks>
/// The offset is applied once, at store time (JR-030), after which the stored
/// windows look native and nothing would say they had been shifted. This block
/// is what makes that reconstructable: which comparison produced the offset, how
/// strong it was, and the local file's own signature, so a later fetch can
/// re-align without decoding the media again.
/// </remarks>
// TRACES: JR-047 | SR-003
public class TruthAlignment
{
/// <summary>Gets or sets which comparison produced the applied offset.</summary>
[JsonPropertyName("source")]
public AlignmentSource Source { get; set; }
/// <summary>Gets or sets the tier that was applied.</summary>
[JsonPropertyName("tier")]
public MatchTier Tier { get; set; }
/// <summary>Gets or sets the offset applied to every window, in seconds.</summary>
[JsonPropertyName("offset_sec")]
public double OffsetSec { get; set; }
/// <summary>
/// Gets or sets the local alignment's score, or <c>null</c> when none was
/// computed.
/// </summary>
[JsonPropertyName("score")]
public double? Score { get; set; }
/// <summary>
/// Gets or sets the recovered slide between the two analysis windows, in
/// frames, or <c>null</c> when no local alignment was computed.
/// </summary>
/// <remarks>
/// Only part of <see cref="OffsetSec"/>: the rest comes from the two windows
/// being anchored at different points when the runtimes differ.
/// </remarks>
[JsonPropertyName("offset_frames")]
public int? OffsetFrames { get; set; }
/// <summary>
/// Gets or sets the local file's own <c>v1:</c> audio signature, or
/// <c>null</c> when none was computed.
/// </summary>
/// <remarks>
/// Kept so a later fetch can align against a new manifest without running
/// FFmpeg over the media again — the expensive half of the operation, and
/// the reason signatures are opt-in. It never leaves the instance: it is
/// stored beside the truth file, and contribution strips provenance
/// entirely (JR-034).
/// </remarks>
[JsonPropertyName("local_signature")]
public string? LocalSignature { get; set; }
/// <summary>
/// Gets or sets the offset the server claimed, retained even when a local
/// alignment superseded it.
/// </summary>
[JsonPropertyName("server_offset_sec")]
public double ServerOffsetSec { get; set; }
/// <summary>Gets or sets the tier the server claimed.</summary>
[JsonPropertyName("server_tier")]
public MatchTier ServerTier { get; set; }
}
+35
View File
@@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Which encode the timings in a <see cref="TruthFile"/> apply to (SPEC.md §1,
/// JR-002).
/// </summary>
/// <remarks>
/// Timings are only meaningful against a particular cut. Recording the runtime
/// they were measured from is what lets a consumer notice that a file has been
/// re-encoded, re-trimmed, or replaced with a different release — rather than
/// silently showing an actor twenty seconds late.
/// </remarks>
// TRACES: JR-002 | SR-003
public class TruthCut
{
/// <summary>
/// Gets or sets the decoded duration the timings came from, in seconds.
/// </summary>
[JsonPropertyName("runtime_sec")]
public double? RuntimeSec { get; set; }
/// <summary>
/// Gets or sets the version-prefixed spectral-peak audio signature, or
/// <c>null</c> when the producer emitted none.
/// </summary>
/// <remarks>
/// Carries its own <c>v1:</c> prefix so a DSP change is detectable rather
/// than silently non-matching (JR-045). Media shorter than 120 s carries no
/// signature at all (JR-044).
/// </remarks>
[JsonPropertyName("audio_signature")]
public string? AudioSignature { get; set; }
}
@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Extraction provenance for a <see cref="TruthFile"/> (SPEC.md §1, JR-002).
/// </summary>
/// <remarks>
/// These fields moved here from the top level in the SR-003 bump so that the
/// truth file and the Jmanifest's <c>extraction</c> block have the same shape.
/// They differed for no reason, and two nearly-identical shapes are what makes a
/// converter quietly drop a field.
/// <para>
/// <b>There is no <c>anneal_sec</c>.</b> It was withdrawn rather than retained
/// as a vestigial zero: presence now follows track extent, so a track survives
/// its own gaps and there is nothing to anneal (extraction AR-012/AR-013). A
/// field naming a mechanism the pipeline no longer has is actively misleading,
/// and would outlive everyone who remembers why it reads zero.
/// </para>
/// </remarks>
// TRACES: JR-002 | SR-003
public class TruthExtraction
{
/// <summary>Gets or sets the sampling rate used during extraction.</summary>
[JsonPropertyName("sample_fps")]
public double? SampleFps { get; set; }
/// <summary>
/// Gets or sets the re-acquisition timeout that shapes window extent, in
/// seconds. Successor to the withdrawn <c>anneal_sec</c>.
/// </summary>
/// <remarks>
/// This is what a consumer needs in order to interpret a window: it bounds
/// how long an actor could be unseen without the window being closed.
/// </remarks>
[JsonPropertyName("extinction_sec")]
public double? ExtinctionSec { get; set; }
/// <summary>Gets or sets the producing pipeline's version string.</summary>
[JsonPropertyName("pipeline_version")]
public string? PipelineVersion { get; set; }
/// <summary>Gets or sets how many references the gallery held.</summary>
[JsonPropertyName("gallery_size")]
public int? GallerySize { get; set; }
/// <summary>
/// Gets or sets <c>global</c> or <c>limited</c> — the strongest single
/// quality signal when two manifests compete for one cut.
/// </summary>
[JsonPropertyName("gallery_scope")]
public string? GalleryScope { get; set; }
}
+34 -9
View File
@@ -5,12 +5,30 @@ namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Root object of a scene-actor-extraction "truth" file
/// (schema_version 1, minimal verbosity). See SPEC.md.
/// (<c>schema_version</c> 2). See SPEC.md §1.
/// </summary>
/// <remarks>
/// JRay <b>owns</b> this format; the extraction pipeline is its producer and the
/// public server carries a derived envelope. Because three repos ship
/// independently, breaking changes are batched into one coordinated
/// <c>schema_version</c> bump rather than made piecemeal.
///
/// <para>
/// <b>This is the v2 shape, and v1 is gone rather than deprecated.</b> The bump
/// removed <c>anneal_sec</c>, moved <c>sample_fps</c> into
/// <see cref="TruthExtraction"/>, added <see cref="TruthCut"/>, and turned
/// <c>scenes</c> from float pairs into <see cref="TruthScene"/> objects carrying
/// belief and route. Nothing here reads a v1 file: see
/// <see cref="Services.TruthSchema"/> for why that is a decision rather than an
/// omission.
/// </para>
/// </remarks>
// TRACES: JR-001, JR-002 | SR-003
public class TruthFile
{
/// <summary>
/// Gets or sets the schema version of this file.
/// Gets or sets the schema version of this file. Only
/// <see cref="Services.TruthSchema.SupportedVersion"/> is accepted.
/// </summary>
[JsonPropertyName("schema_version")]
public int SchemaVersion { get; set; }
@@ -18,24 +36,31 @@ public class TruthFile
/// <summary>
/// Gets or sets the source media path at extraction time (informational).
/// </summary>
/// <remarks>
/// Stripped on contribution (JR-034): it is a contributor's directory
/// layout, which is nobody else's business and identifies them.
/// </remarks>
[JsonPropertyName("movie")]
public string Movie { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the sampling rate (frames per second) used during extraction.
/// Gets or sets extraction provenance, or <c>null</c> when the producer
/// recorded none.
/// </summary>
[JsonPropertyName("sample_fps")]
public double SampleFps { get; set; }
[JsonPropertyName("extraction")]
public TruthExtraction? Extraction { get; set; }
/// <summary>
/// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene.
/// Gets or sets which encode the timings apply to, or <c>null</c> when the
/// producer recorded none.
/// </summary>
[JsonPropertyName("anneal_sec")]
public double AnnealSec { get; set; }
[JsonPropertyName("cut")]
public TruthCut? Cut { get; set; }
/// <summary>
/// Gets the list of actors detected in the film, each with their on-screen scene windows.
/// Gets the list of actors in the film, each with their scene-presence windows.
/// </summary>
[JsonPropertyName("actors")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<TruthActor> Actors { get; } = new();
}
@@ -0,0 +1,110 @@
using System;
using System.Text.Json.Serialization;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Where an item's truth data came from.
/// </summary>
public enum TruthSource
{
/// <summary>A <c>.jray.json</c> file beside the media, written locally.</summary>
Sidecar = 0,
/// <summary>Pushed over HTTP by a worker that cannot write beside the media.</summary>
Pushed = 1,
/// <summary>Fetched from a manifest server and converted to a truth file.</summary>
Fetched = 2,
}
/// <summary>
/// How an item's truth data was obtained, recorded alongside it.
/// </summary>
/// <remarks>
/// The three sources are not interchangeable. A locally computed sidecar and a
/// <c>loose</c>-tier manifest from a third-party server make claims of very
/// different strength about the same item, and once stored they are otherwise
/// indistinguishable — the truth file itself records nothing about how it
/// arrived.
///
/// <para>
/// This is stored <b>beside</b> the truth file rather than inside it. Injecting
/// fields would mean the bytes served back are not the bytes the producer wrote,
/// which is the property JR-004 turns on.
/// </para>
/// </remarks>
// TRACES: JR-010, JR-036 | PR-001
public class TruthProvenance
{
/// <summary>
/// Gets or sets which of the three routes delivered this truth data.
/// </summary>
[JsonPropertyName("source")]
public TruthSource Source { get; set; }
/// <summary>
/// Gets or sets the server a fetched manifest came from. Empty for the
/// local sources, whose origin is this instance.
/// </summary>
[JsonPropertyName("server_url")]
public string ServerUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the cut-match tier a fetched manifest reached.
/// </summary>
/// <remarks>
/// Null for local sources: a sidecar or a push is about *this* file, so
/// there is no cut to match. The tier is what makes a fetched claim
/// interpretable — <c>loose</c> means "probably the same cut", which the UI
/// must surface rather than apply silently (JR-036).
/// </remarks>
[JsonPropertyName("match_tier")]
public MatchTier? MatchTier { get; set; }
/// <summary>
/// Gets or sets the offset, in seconds, applied to every window before
/// storage so the stored timings are in this file's own timebase (JR-030).
/// </summary>
/// <remarks>
/// Recorded because it is otherwise unrecoverable: once applied, the stored
/// windows look native, and nothing would say they had been shifted.
/// </remarks>
[JsonPropertyName("offset_sec")]
public double OffsetSec { get; set; }
/// <summary>
/// Gets or sets how the applied offset was arrived at, or null for local
/// sources and for fetches made before alignment was recorded.
/// </summary>
/// <remarks>
/// <see cref="OffsetSec"/> says what was applied; this says why, and keeps
/// the local file's own signature so a later fetch can re-align without
/// decoding the media again (JR-047).
/// </remarks>
[JsonPropertyName("alignment")]
public TruthAlignment? Alignment { get; set; }
/// <summary>
/// Gets or sets a human-readable caveat to surface with the overlay, or
/// null when the claim needs none.
/// </summary>
[JsonPropertyName("caveat")]
public string? Caveat { get; set; }
/// <summary>
/// Gets or sets when this truth data was recorded, UTC.
/// </summary>
[JsonPropertyName("recorded_at")]
public DateTime RecordedAt { get; set; }
/// <summary>
/// Creates provenance for truth data produced on this instance.
/// </summary>
/// <param name="source">Either <see cref="TruthSource.Sidecar"/> or <see cref="TruthSource.Pushed"/>.</param>
/// <param name="recordedAt">When it was recorded, UTC.</param>
/// <returns>The provenance record.</returns>
public static TruthProvenance Local(TruthSource source, DateTime recordedAt)
=> new() { Source = source, RecordedAt = recordedAt };
}
+50
View File
@@ -0,0 +1,50 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One presence window in a <see cref="TruthFile"/>.
/// </summary>
/// <remarks>
/// <b>A window is a claim about scene membership, not a recognition event</b>
/// (SR-002). An actor who turns away, is occluded, or is off-camera while the
/// shot cuts to whoever they are speaking to is still present — so a consumer
/// must never read a boundary as "the face was detected here", and must not
/// merge, split, trim or reorder windows.
/// <para>
/// In <c>schema_version</c> 1 this was a bare <c>[start, end]</c> float pair. It
/// became an object in the SR-003 bump so a window can carry the evidence behind
/// it: a consumer that shows presence should be able to say how strongly it is
/// believed and how it was arrived at, which a pair of numbers cannot express.
/// </para>
/// </remarks>
// TRACES: JR-002, JR-004 | SR-002, SR-003
public class TruthScene
{
/// <summary>Gets or sets the window start, in seconds, inclusive.</summary>
[JsonPropertyName("start")]
public double Start { get; set; }
/// <summary>Gets or sets the window end, in seconds, inclusive.</summary>
[JsonPropertyName("end")]
public double End { get; set; }
/// <summary>
/// Gets or sets the accumulated posterior that justified this claim, in
/// <c>[0, 1]</c>, or <c>null</c> when the producer did not record one.
/// </summary>
/// <remarks>
/// Optional rather than defaulted to zero: absent and "believed with
/// probability zero" are different statements, and a claim nobody believes
/// would not have been written.
/// </remarks>
[JsonPropertyName("belief")]
public double? Belief { get; set; }
/// <summary>
/// Gets or sets how the actor was identified: <c>live</c>, <c>deferred</c>
/// or <c>pooled</c> (extraction AR-017).
/// </summary>
[JsonPropertyName("route")]
public string? Route { get; set; }
}
+34 -2
View File
@@ -17,6 +17,7 @@ namespace Jellyfin.Plugin.JRay;
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
private readonly ILogger<Plugin> _logger;
private readonly bool _usingFileTransformation;
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
@@ -30,8 +31,25 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
Instance = this;
_logger = logger;
WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
ConfigurationChanged += (_, _) => WebClientPatchService.Apply(ApplicationPaths, Configuration.EnableOverlay, _logger);
// The File Transformation plugin rewrites index.html as it is served.
// Registration is unconditional: the transformation itself checks
// EnableOverlay at request time, so toggling the setting takes effect
// without re-registering.
_usingFileTransformation = FileTransformationRegistration.TryRegister(_logger);
if (!_usingFileTransformation)
{
_logger.LogWarning(
"JRay: the pause overlay is disabled because the File Transformation plugin " +
"is not available. Install it from " +
"https://github.com/IAmParadox27/jellyfin-plugin-file-transformation. " +
"All other JRay features are unaffected.");
}
// JRay never injects into index.html. This only ever *removes* a patch
// left by an earlier version of JRay, identified by its own marker —
// see SPEC.md JR-021/JR-022.
WebClientPatchService.RemoveLegacyPatch(ApplicationPaths, _logger);
}
/// <inheritdoc />
@@ -45,6 +63,20 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
/// </summary>
public static Plugin? Instance { get; private set; }
/// <summary>
/// Gets a value indicating whether the pause overlay should currently be
/// served. Read by the File Transformation callback at request time, so
/// toggling the setting takes effect without re-registering.
/// </summary>
internal static bool OverlayEnabled => Instance?.Configuration.EnableOverlay ?? false;
/// <summary>
/// Gets a value indicating whether the File Transformation plugin was found
/// at startup. When it was not, the overlay is disabled and every other JRay
/// feature continues to work.
/// </summary>
internal static bool FileTransformationAvailable => Instance?._usingFileTransformation ?? false;
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
@@ -16,5 +16,19 @@ public class ServiceRegistrator : IPluginServiceRegistrator
{
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
serviceCollection.AddSingleton<IMediaPolicyStore, MediaPolicyStore>();
// Singleton so per-server backoff state survives across requests: a
// server that is down should be skipped for the whole sweep, not
// retried once per item (JR-037).
serviceCollection.AddSingleton<IManifestExchangeClient, ManifestExchangeClient>();
// Registered concretely: there is no second implementation for an
// interface to abstract over and nothing to gain from inventing one
// (JR-042). The aligner consumes the signature on the fetch path, so a
// manifest is checked against the local file before its windows are
// stored (JR-047).
serviceCollection.AddSingleton<AudioSignatureService>();
serviceCollection.AddSingleton<ManifestAligner>();
}
}
@@ -0,0 +1,373 @@
using System;
using System.Collections.Generic;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// The JRay v1 content-derived audio signature: spectral peak bins taken from
/// the centre of a media file, so a truth file is self-identifying.
/// </summary>
/// <remarks>
/// The construction is owned by the public server specification §3 and is
/// implemented a second time, in C++, by the extraction pipeline
/// (<c>src/audio_signature.*</c>, extraction <c>IR-004</c>). <b>The two must
/// agree byte for byte</b> — a signature that differs in any parameter simply
/// does not match, which defeats the entire point of having one. Every constant
/// below is therefore load-bearing, and any change to one is a change to the
/// <c>v1:</c> prefix as well.
/// <para>
/// The server specification's prose is not sufficient to reproduce a byte
/// stream, so the details it leaves open are pinned by the golden fixture
/// shared with the extraction repo (JR-043,
/// <c>fixtures/audio/jray_audio_v1_golden.json</c>), and restated here:
/// </para>
/// <list type="bullet">
/// <item>Arithmetic is IEEE-754 <c>double</c> throughout. <c>float</c> is not
/// sufficient: the fixture has frames whose two strongest bands are within 1.3%
/// of each other.</item>
/// <item>Samples arrive as FFmpeg's native <c>s16 -&gt; flt</c> conversion,
/// <c>x * (1/32768)</c>, widened to double here.</item>
/// <item>Whole frames only:
/// <c>n_frames = 1 + (n_samples - 4096) / 1024</c>, integer division.</item>
/// <item>Hann window, <b>periodic</b>: <c>0.5 * (1 - cos(2*pi*n/4096))</c>, not
/// the symmetric <c>N-1</c> variant.</item>
/// <item>Plain radix-2 FFT, no normalisation; magnitude is
/// <c>sqrt(re^2 + im^2)</c>.</item>
/// <item>A band's value is the <b>mean</b> of the linear magnitudes in it, so a
/// wide high band is not favoured over a narrow low one.</item>
/// <item>The peak is the <c>argmax</c> over the 32 bands, ties to the lowest
/// index. The specification's log is a monotone squash and so cannot change an
/// argmax; it is applied only where it is observable, in the energy class.</item>
/// </list>
/// <para>
/// The FFT is written out here rather than taken from a library for the same
/// reason the pipeline writes its own: it is a fixed, fully specified transform,
/// and a dependency whose version could change the numerics is a liability when
/// the output has to be identical across two languages.
/// </para>
/// </remarks>
// TRACES: JR-042 | SR-003
public static class AudioSignature
{
/// <summary>Sample rate the signature is computed at, in Hz.</summary>
public const int SampleRate = 11025;
/// <summary>STFT frame size, in samples.</summary>
public const int FrameSize = 4096;
/// <summary>STFT hop size, in samples (~93 ms).</summary>
public const int HopSize = 1024;
/// <summary>Number of logarithmically spaced bands.</summary>
public const int NumBands = 32;
/// <summary>Low edge of the analysed band, in Hz.</summary>
public const double BandLoHz = 300.0;
/// <summary>High edge of the analysed band, in Hz.</summary>
public const double BandHiHz = 3000.0;
/// <summary>Length of the analysed window, in seconds.</summary>
public const double WindowSec = 120.0;
/// <summary>
/// Length of the analysed window, in samples (120.000 s at 11025 Hz).
/// </summary>
/// <remarks>
/// The decoded window is truncated to exactly this, so the frame count is
/// the same for every input and does not wobble with seek granularity or a
/// resampler tail.
/// </remarks>
public const int WindowSamples = 1323000;
/// <summary>Frames a full window yields: <c>1 + (1323000 - 4096) / 1024</c>.</summary>
/// <remarks>
/// The server specification says "~1290" and accepts a tolerance; the exact
/// count follows from the framing rule and is 1288.
/// </remarks>
public const int ExpectedFrames = 1288;
/// <summary>Guard added to both sides of the energy ratio.</summary>
public const double EnergyEps = 1e-12;
/// <summary>
/// The signature's own version prefix, separate from <c>schema_version</c>.
/// </summary>
/// <remarks>
/// A future change to the DSP chain must be <i>detectable</i> rather than
/// silently producing signatures that no longer match (JR-045).
/// </remarks>
public const string VersionPrefix = "v1:";
// Class thresholds on log10(E_frame / E_median). They deliberately straddle
// r = 0 rather than sit on it, so the median frame itself is never on a
// boundary.
private static readonly double[] EnergyClassEdges = [-0.6, -0.2, 0.2];
private static readonly (int Low, int High)[] Bands = BuildBandTable();
private static readonly double[] Window = BuildHannWindow();
private static readonly int[] BitReversal = BuildBitReversal();
private static readonly double[][] TwiddleReal = BuildTwiddles(cosine: true);
private static readonly double[][] TwiddleImag = BuildTwiddles(cosine: false);
/// <summary>
/// Gets the half-open FFT bin range <c>[Low, High)</c> owned by each of the
/// 32 log-spaced bands.
/// </summary>
/// <remarks>
/// Exposed so the golden fixture can assert the table itself rather than
/// only the signature it produces: the band table is the part of the
/// construction most likely to drift between two implementations.
/// </remarks>
/// <returns>One range per band, contiguous and non-overlapping.</returns>
public static IReadOnlyList<(int Low, int High)> BandFftBins() => Bands;
/// <summary>
/// Packs one byte per whole STFT frame: a 5-bit peak band index and a 2-bit
/// energy class.
/// </summary>
/// <remarks>
/// The byte layout is <c>(band &lt;&lt; 2) | class</c>, so bit 7 is always
/// clear and an arbitrary byte is not a valid signature. That structural
/// constraint is what the server validates on upload, and what keeps the
/// field from being usable as a payload channel.
/// </remarks>
/// <param name="mono">Mono samples at <see cref="SampleRate"/>, in [-1, 1).</param>
/// <returns>One byte per frame; empty when not even one frame fits.</returns>
public static byte[] PackFrames(ReadOnlySpan<float> mono)
{
if (mono.Length < FrameSize)
{
return [];
}
var frames = 1 + ((mono.Length - FrameSize) / HopSize);
var binLow = Bands[0].Low;
var binHigh = Bands[NumBands - 1].High; // exclusive
double binCount = binHigh - binLow;
var re = new double[FrameSize];
var im = new double[FrameSize];
var peak = new byte[frames];
var energy = new double[frames];
for (var f = 0; f < frames; f++)
{
var src = mono.Slice(f * HopSize, FrameSize);
for (var n = 0; n < FrameSize; n++)
{
re[n] = src[n] * Window[n];
im[n] = 0.0;
}
Fft(re, im);
// Per-band mean magnitude. The bands tile 300-3000 Hz with no gaps
// and no overlaps, so the frame's band-limited energy is the sum of
// the band sums — accumulated in band order, because the order of a
// floating-point summation is part of the contract.
var best = -1.0;
var bestBand = 0;
var total = 0.0;
for (var b = 0; b < NumBands; b++)
{
var (low, high) = Bands[b];
var sum = 0.0;
for (var k = low; k < high; k++)
{
sum += Math.Sqrt((re[k] * re[k]) + (im[k] * im[k]));
}
total += sum;
var mean = sum / (high - low);
if (mean > best)
{
best = mean; // ties -> lowest index
bestBand = b;
}
}
peak[f] = (byte)bestBand;
energy[f] = total / binCount;
}
// The reference is the upper median of the frame energies: an actually
// observed value rather than the average of the two middle ones, so it
// is bit-reproducible. It is also gain-invariant — loudness
// normalisation must not change a signature — and barely moves when the
// window is trimmed.
var sorted = (double[])energy.Clone();
Array.Sort(sorted);
var reference = sorted[sorted.Length / 2];
var packed = new byte[frames];
for (var f = 0; f < frames; f++)
{
var r = Math.Log10((energy[f] + EnergyEps) / (reference + EnergyEps));
packed[f] = (byte)(((peak[f] & 0x1F) << 2) | (EnergyClass(r) & 0x03));
}
return packed;
}
/// <summary>
/// Computes the full signature string for a decoded centre window.
/// </summary>
/// <param name="mono">Mono samples at <see cref="SampleRate"/>, in [-1, 1).</param>
/// <returns>
/// <c>v1:</c> followed by the base64 of <see cref="PackFrames"/>, or
/// <c>null</c> when not even one frame fits.
/// </returns>
public static string? FromMonoSamples(ReadOnlySpan<float> mono)
{
var packed = PackFrames(mono);
if (packed.Length == 0)
{
return null;
}
// Standard alphabet with '=' padding, which is what the server and the
// pipeline both emit.
return VersionPrefix + Convert.ToBase64String(packed);
}
private static int EnergyClass(double ratio)
{
if (ratio < EnergyClassEdges[0])
{
return 0;
}
if (ratio < EnergyClassEdges[1])
{
return 1;
}
if (ratio < EnergyClassEdges[2])
{
return 2;
}
return 3;
}
// edge[b] = 300 * (3000/300)^(b/32); band b owns FFT bins
// [k_lo[b], k_lo[b+1]) with k_lo[b] = ceil(edge[b] / hz_per_bin). Taking the
// ceiling once, into an integer table, means membership is never decided by
// a float comparison per bin per frame — which is where two implementations
// would otherwise be free to disagree.
private static (int Low, int High)[] BuildBandTable()
{
var hzPerBin = (double)SampleRate / FrameSize;
var edges = new int[NumBands + 1];
for (var b = 0; b <= NumBands; b++)
{
var hz = BandLoHz * Math.Pow(BandHiHz / BandLoHz, (double)b / NumBands);
edges[b] = (int)Math.Ceiling(hz / hzPerBin);
}
var table = new (int Low, int High)[NumBands];
for (var b = 0; b < NumBands; b++)
{
table[b] = (edges[b], edges[b + 1]);
}
return table;
}
private static double[] BuildHannWindow()
{
var w = new double[FrameSize];
for (var n = 0; n < FrameSize; n++)
{
w[n] = 0.5 * (1.0 - Math.Cos(2.0 * Math.PI * n / FrameSize));
}
return w;
}
private static int[] BuildBitReversal()
{
var bits = 0;
while ((1 << bits) < FrameSize)
{
bits++;
}
var rev = new int[FrameSize];
for (var i = 0; i < FrameSize; i++)
{
var r = 0;
for (var b = 0; b < bits; b++)
{
if ((i & (1 << b)) != 0)
{
r |= 1 << (bits - 1 - b);
}
}
rev[i] = r;
}
return rev;
}
// Twiddles are precomputed per stage from cos/sin of -2*pi*j/len, so the
// angle is an exactly reproducible double in either language and only the
// library's own rounding of cos/sin (<= 1 ulp) can differ — orders of
// magnitude below the decision margins the golden fixture records.
private static double[][] BuildTwiddles(bool cosine)
{
var stages = new List<double[]>();
for (var len = 2; len <= FrameSize; len <<= 1)
{
var half = len / 2;
var stage = new double[half];
for (var j = 0; j < half; j++)
{
var angle = -2.0 * Math.PI * j / len;
stage[j] = cosine ? Math.Cos(angle) : Math.Sin(angle);
}
stages.Add(stage);
}
return [.. stages];
}
// Radix-2 decimation-in-time complex FFT, in place, no normalisation.
private static void Fft(double[] re, double[] im)
{
for (var i = 0; i < FrameSize; i++)
{
var j = BitReversal[i];
if (i < j)
{
(re[i], re[j]) = (re[j], re[i]);
(im[i], im[j]) = (im[j], im[i]);
}
}
var stage = 0;
for (var len = 2; len <= FrameSize; len <<= 1, stage++)
{
var half = len / 2;
var wr = TwiddleReal[stage];
var wi = TwiddleImag[stage];
for (var start = 0; start < FrameSize; start += len)
{
for (var j = 0; j < half; j++)
{
var a = start + j;
var b = a + half;
var tr = (re[b] * wr[j]) - (im[b] * wi[j]);
var ti = (re[b] * wi[j]) + (im[b] * wr[j]);
re[b] = re[a] - tr;
im[b] = im[a] - ti;
re[a] += tr;
im[a] += ti;
}
}
}
}
}
@@ -0,0 +1,70 @@
using System;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// The outcome of comparing a local audio signature against a remote one.
/// </summary>
/// <remarks>
/// Produced only when the two signatures actually align; a comparison that
/// reaches no tier yields no result at all rather than a zero-scored one, so a
/// caller cannot mistake "did not match" for "matched at the bottom".
/// </remarks>
public readonly struct AudioSignatureMatch : IEquatable<AudioSignatureMatch>
{
/// <summary>
/// Gets the fraction of overlapping frames whose peak band agreed, in [0, 1].
/// </summary>
public double Score { get; init; }
/// <summary>
/// Gets the best-scoring alignment, in frames, between the two analysis
/// windows.
/// </summary>
/// <remarks>
/// Positive means the local window lags the remote one. This is a
/// window-relative quantity and is <b>not</b> the offset to apply to
/// timings — see <see cref="OffsetSec"/>, which additionally accounts for
/// the two windows being anchored at different points in their files.
/// </remarks>
public int OffsetFrames { get; init; }
/// <summary>
/// Gets the seconds to add to every remote window to bring it into the local
/// file's timebase.
/// </summary>
/// <remarks>
/// This is the quantity JR-030 applies at store time, and the one
/// <see cref="ManifestConverter.ToTruthFile"/> takes.
/// </remarks>
public double OffsetSec { get; init; }
/// <summary>Gets the tier this score earns.</summary>
public MatchTier Tier { get; init; }
/// <summary>Compares two matches for equality.</summary>
/// <param name="left">Left operand.</param>
/// <param name="right">Right operand.</param>
/// <returns><c>true</c> when the two are equal.</returns>
public static bool operator ==(AudioSignatureMatch left, AudioSignatureMatch right) => left.Equals(right);
/// <summary>Compares two matches for inequality.</summary>
/// <param name="left">Left operand.</param>
/// <param name="right">Right operand.</param>
/// <returns><c>true</c> when the two differ.</returns>
public static bool operator !=(AudioSignatureMatch left, AudioSignatureMatch right) => !left.Equals(right);
/// <inheritdoc/>
public bool Equals(AudioSignatureMatch other)
=> Score.Equals(other.Score)
&& OffsetFrames == other.OffsetFrames
&& OffsetSec.Equals(other.OffsetSec)
&& Tier == other.Tier;
/// <inheritdoc/>
public override bool Equals(object? obj) => obj is AudioSignatureMatch other && Equals(other);
/// <inheritdoc/>
public override int GetHashCode() => HashCode.Combine(Score, OffsetFrames, OffsetSec, Tier);
}
@@ -0,0 +1,207 @@
using System;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Reads v1 audio signatures and aligns two of them, recovering the time offset
/// between differently trimmed releases of one cut.
/// </summary>
/// <remarks>
/// The <b>consumer</b> half of the signature feature: <see cref="AudioSignature"/>
/// produces, this reads. Matching belongs to the plugin rather than the server
/// because the offset is applied client-side (JR-030) and manifests are never
/// rewritten — one stored manifest serves every trim of the same cut.
/// <para>
/// The construction is public server specification §3 "Matching and offset
/// recovery": slide one signature against the other over ±600 frames, score the
/// fraction of overlapping frames whose <b>peak band</b> agrees, and take the
/// argmax. The energy class is deliberately not scored — it is the coarser and
/// less re-encoding-stable of the two fields, and the specification's rule names
/// the peak bin alone.
/// </para>
/// </remarks>
// TRACES: JR-044, JR-045 | SR-003
public static class AudioSignatureMatcher
{
/// <summary>Widest alignment searched, in frames (±56 s).</summary>
/// <remarks>
/// Covers realistic trim differences. A release that differs in *speed*
/// (a PAL 4% speed-up) is not a constant offset and is correctly rejected by
/// the score threshold rather than mis-aligned by this search.
/// </remarks>
public const int MaxOffsetFrames = 600;
/// <summary>Score at or above which two signatures are the same cut.</summary>
public const double AudioThreshold = 0.85;
/// <summary>Score at or above which two signatures are possibly the same cut.</summary>
public const double LooseThreshold = 0.60;
/// <summary>
/// Fewest overlapping frames an alignment must have before its score counts.
/// </summary>
/// <remarks>
/// <b>Not from the specification</b> — a guard this implementation adds. The
/// slide is defined over overlapping frames, and without a floor the extreme
/// offsets compare a handful of frames, where a chance agreement scores 1.0
/// and beats the true alignment. 64 frames is ~6 s. It only ever excludes
/// alignments near the ±600 limit: two full-length signatures overlap by 688
/// frames even at the widest offset, so this never binds on the case the
/// feature exists for.
/// </remarks>
public const int MinOverlapFrames = 64;
/// <summary>Gets the duration one STFT frame advances, in seconds.</summary>
public static double FrameSeconds => (double)AudioSignature.HopSize / AudioSignature.SampleRate;
/// <summary>
/// Parses a signature string into its per-frame bytes, refusing anything that
/// is not a well-formed <c>v1:</c> signature.
/// </summary>
/// <remarks>
/// JR-045. An unknown prefix is <b>refused, not parsed</b>: a <c>v2:</c>
/// signature from a future producer describes a DSP chain this build does not
/// implement, so scoring it as v1 would silently produce a wrong answer where
/// declining produces a correct one — the item simply falls to the runtime
/// tier. That is the entire purpose of the prefix being separate from
/// <c>schema_version</c>.
/// <para>
/// Structure is checked too, matching what the server validates on upload:
/// every byte is a 5-bit band index and a 2-bit energy class, so bit 7 is
/// always clear.
/// </para>
/// </remarks>
/// <param name="signature">A signature string, or <c>null</c>.</param>
/// <returns>The frame bytes, or <c>null</c> if this is not a v1 signature.</returns>
public static byte[]? TryParseFrames(string? signature)
{
if (string.IsNullOrEmpty(signature)
|| !signature.StartsWith(AudioSignature.VersionPrefix, StringComparison.Ordinal))
{
return null;
}
var payload = signature[AudioSignature.VersionPrefix.Length..];
Span<byte> decoded = new byte[((payload.Length / 4) + 1) * 3];
if (!Convert.TryFromBase64String(payload, decoded, out var written) || written == 0)
{
return null;
}
var frames = decoded[..written].ToArray();
foreach (var frame in frames)
{
if ((frame & 0x80) != 0)
{
return null;
}
}
return frames;
}
/// <summary>
/// Aligns a local signature against a remote one and reports the tier and
/// offset it earns.
/// </summary>
/// <remarks>
/// Returns <c>null</c> — no match, and therefore <b>no offset</b> — when
/// either signature is absent or is not a v1 signature (JR-045), when either
/// item is shorter than the analysis window (JR-044), or when the best
/// alignment scores below <see cref="LooseThreshold"/>.
/// <para>
/// The short-media rule is checked on the runtimes rather than inferred from
/// a missing signature, because the two producers must apply the identical
/// rule and the runtime is what both of them test. An item under 120 s falls
/// back to the runtime tier, which is adequate: a sub-two-minute item is
/// rarely the ambiguous-providence case the signature exists to solve.
/// </para>
/// </remarks>
/// <param name="localSignature">Signature computed from the local file.</param>
/// <param name="remoteSignature">Signature carried by the fetched manifest.</param>
/// <param name="localRuntimeSec">Runtime of the local file, in seconds.</param>
/// <param name="remoteRuntimeSec">Runtime the manifest records, in seconds.</param>
/// <returns>The match, or <c>null</c> when the two do not align.</returns>
public static AudioSignatureMatch? Compare(
string? localSignature,
string? remoteSignature,
double localRuntimeSec,
double remoteRuntimeSec)
{
// JR-044 — below the window there is no signature to trust and no offset
// to apply, whatever the strings happen to contain.
if (localRuntimeSec < AudioSignature.WindowSec || remoteRuntimeSec < AudioSignature.WindowSec)
{
return null;
}
var local = TryParseFrames(localSignature);
var remote = TryParseFrames(remoteSignature);
if (local is null || remote is null)
{
return null;
}
var bestScore = -1.0;
var bestOffset = 0;
var found = false;
for (var d = -MaxOffsetFrames; d <= MaxOffsetFrames; d++)
{
// i indexes the remote signature; the local frame it is compared
// against is i + d, which must fall inside the local signature.
var first = Math.Max(0, -d);
var last = Math.Min(remote.Length, local.Length - d);
var overlap = last - first;
if (overlap < MinOverlapFrames)
{
continue;
}
var agreed = 0;
for (var i = first; i < last; i++)
{
if (((local[i + d] >> 2) & 0x1F) == ((remote[i] >> 2) & 0x1F))
{
agreed++;
}
}
var score = (double)agreed / overlap;
// Ties go to the alignment closest to zero: when a signature is
// degenerate enough that several offsets score alike, "not shifted"
// is the reading that does least damage, and the choice must be
// deterministic rather than an artefact of iteration order.
if (!found || score > bestScore || (score == bestScore && Math.Abs(d) < Math.Abs(bestOffset)))
{
bestScore = score;
bestOffset = d;
found = true;
}
}
if (!found || bestScore < LooseThreshold)
{
return null;
}
// Both windows are centred on their own file's midpoint, so an offset
// between the two windows is only part of the answer: the windows start
// at different absolute times whenever the runtimes differ, and that
// difference is the rest of it. Runtimes are >= WindowSec here, so
// neither start clamps at zero — the same condition under which
// AudioSignatureService does not clamp either.
var localStart = (localRuntimeSec / 2.0) - (AudioSignature.WindowSec / 2.0);
var remoteStart = (remoteRuntimeSec / 2.0) - (AudioSignature.WindowSec / 2.0);
return new AudioSignatureMatch
{
Score = bestScore,
OffsetFrames = bestOffset,
OffsetSec = (localStart - remoteStart) + (bestOffset * FrameSeconds),
Tier = bestScore >= AudioThreshold ? MatchTier.Audio : MatchTier.Loose,
};
}
}
@@ -0,0 +1,285 @@
using System;
using System.Buffers.Binary;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using MediaBrowser.Controller.MediaEncoding;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Computes the v1 audio signature for a media file, decoding its centre window
/// with the FFmpeg binary Jellyfin already ships.
/// </summary>
/// <remarks>
/// <b>No new dependency.</b> FFmpeg performs decode, downmix and resample; the
/// plugin adds only the fixed FFT and bin-peak extraction in
/// <see cref="AudioSignature"/>. The binary is reached through
/// <see cref="IMediaEncoder.EncoderPath"/>, so an installation that can
/// transcode can compute signatures, with nothing further to install and no
/// second copy of FFmpeg to keep in step.
/// <para>
/// The pipeline computes the same signature for files it processes locally
/// (extraction <c>IR-004</c>); this exists for the files it never sees. Both
/// producers must therefore agree exactly, including on the decode: the command
/// below is the CLI spelling of what the pipeline asks libswresample for — best
/// audio stream, mono, 11025 Hz, 32-bit float — and the golden fixture pins the
/// decoded PCM as well as the signature, so a codec-level divergence is
/// distinguishable from a DSP-level one (JR-043).
/// </para>
/// <para>
/// <b>Every failure degrades to no signature rather than to an error.</b> A
/// signature is an enhancement to cut matching; a missing one costs a tier, and
/// must never be able to break a fetch.
/// </para>
/// </remarks>
// TRACES: JR-042 | SR-003
public class AudioSignatureService
{
private readonly IMediaEncoder _encoder;
private readonly ILogger<AudioSignatureService> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AudioSignatureService"/> class.
/// </summary>
/// <param name="encoder">Supplies the path of the FFmpeg binary Jellyfin ships.</param>
/// <param name="logger">Logger.</param>
public AudioSignatureService(IMediaEncoder encoder, ILogger<AudioSignatureService> logger)
{
_encoder = encoder;
_logger = logger;
}
/// <summary>
/// Computes the signature of the 120 s window centred on the media's
/// midpoint.
/// </summary>
/// <remarks>
/// The centre is used because the head and tail are the least
/// content-specific parts of a release: logos and cold opens at one end,
/// credits at the other.
/// <para>
/// Media shorter than <see cref="AudioSignature.WindowSec"/> yields
/// <c>null</c>: the window underflows, so there is no signature — the
/// identical rule the extraction producer applies, since diverging here
/// would break exactly the short items most likely to be misidentified
/// (JR-044, whose remaining half — applying no sync offset — belongs with
/// signature matching).
/// </para>
/// </remarks>
/// <param name="path">Path of the media file.</param>
/// <param name="runtimeSeconds">The item's runtime, as Jellyfin knows it.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>
/// The <c>v1:</c>-prefixed signature, or <c>null</c> for short media, media
/// with no usable audio, and any decode failure.
/// </returns>
public Task<string?> ComputeAsync(
string path,
double runtimeSeconds,
CancellationToken cancellationToken)
=> ComputeWithEncoderAsync(_encoder?.EncoderPath, path, runtimeSeconds, _logger, cancellationToken);
/// <summary>
/// <see cref="ComputeAsync"/> with the FFmpeg binary named explicitly.
/// </summary>
/// <remarks>
/// Internal so the golden-fixture test can drive the real decode path with
/// whatever FFmpeg the machine has, rather than standing up a fake
/// <see cref="IMediaEncoder"/> — a stub of a thirty-member interface would
/// be the larger risk of the two, and it is the decode that is under test.
/// </remarks>
/// <param name="encoderPath">Path of the FFmpeg binary to run.</param>
/// <param name="path">Path of the media file.</param>
/// <param name="runtimeSeconds">The item's runtime, as Jellyfin knows it.</param>
/// <param name="logger">Logger.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The <c>v1:</c>-prefixed signature, or <c>null</c>.</returns>
internal static async Task<string?> ComputeWithEncoderAsync(
string? encoderPath,
string path,
double runtimeSeconds,
ILogger logger,
CancellationToken cancellationToken)
{
if (string.IsNullOrEmpty(path) || runtimeSeconds < AudioSignature.WindowSec)
{
return null;
}
if (string.IsNullOrEmpty(encoderPath))
{
logger.LogDebug("No FFmpeg binary available; skipping the audio signature for {Path}", path);
return null;
}
try
{
var samples = await DecodeCentreWindowAsync(encoderPath, path, runtimeSeconds, logger, cancellationToken)
.ConfigureAwait(false);
if (samples is null)
{
return null;
}
return AudioSignature.FromMonoSamples(samples);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogDebug(ex, "Audio signature failed for {Path}; continuing without one", path);
return null;
}
}
/// <summary>
/// Decodes the centre window as mono 32-bit float PCM at 11025 Hz.
/// </summary>
/// <remarks>
/// The stream is truncated to exactly
/// <see cref="AudioSignature.WindowSamples"/> samples, so the frame count is
/// the same for every input rather than wobbling with seek granularity or a
/// resampler tail.
/// <para>
/// No <c>-map</c> is given: FFmpeg's default audio selection is the same
/// "best stream" choice the pipeline makes with
/// <c>av_find_best_stream</c>, and naming <c>0:a:0</c> instead would pick a
/// different track from the pipeline's on any file whose first audio stream
/// is not its main one — a commentary track, say.
/// </para>
/// </remarks>
private static async Task<float[]?> DecodeCentreWindowAsync(
string encoderPath,
string path,
double runtimeSeconds,
ILogger logger,
CancellationToken cancellationToken)
{
var start = (runtimeSeconds / 2.0) - (AudioSignature.WindowSec / 2.0);
if (start < 0.0)
{
start = 0.0;
}
var startArgument = start.ToString("0.000", CultureInfo.InvariantCulture);
var windowArgument = AudioSignature.WindowSec.ToString("0.000", CultureInfo.InvariantCulture);
var rateArgument = AudioSignature.SampleRate.ToString(CultureInfo.InvariantCulture);
var startInfo = new ProcessStartInfo
{
FileName = encoderPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = false,
UseShellExecute = false,
CreateNoWindow = true,
};
startInfo.ArgumentList.Add("-nostdin");
startInfo.ArgumentList.Add("-v");
startInfo.ArgumentList.Add("error");
// Input seeking, so FFmpeg does not decode the whole file to reach the
// middle of it. Accurate by default: it seeks to the preceding keyframe
// and discards the excess, which is what the pipeline does by hand.
startInfo.ArgumentList.Add("-ss");
startInfo.ArgumentList.Add(startArgument);
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(path);
startInfo.ArgumentList.Add("-t");
startInfo.ArgumentList.Add(windowArgument);
startInfo.ArgumentList.Add("-vn");
startInfo.ArgumentList.Add("-sn");
startInfo.ArgumentList.Add("-dn");
startInfo.ArgumentList.Add("-ac");
startInfo.ArgumentList.Add("1");
startInfo.ArgumentList.Add("-ar");
startInfo.ArgumentList.Add(rateArgument);
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("f32le");
startInfo.ArgumentList.Add("-");
using var process = new Process { StartInfo = startInfo };
if (!process.Start())
{
return null;
}
try
{
// Read stderr concurrently: it is redirected, so leaving it unread
// would deadlock the moment FFmpeg filled the pipe.
var errorTask = process.StandardError.ReadToEndAsync(cancellationToken);
var bytes = await ReadWindowAsync(process.StandardOutput.BaseStream, cancellationToken)
.ConfigureAwait(false);
var error = await errorTask.ConfigureAwait(false);
if (bytes.Length < AudioSignature.FrameSize * sizeof(float))
{
// No audio stream, an unreadable file, or a runtime Jellyfin
// knows but the container does not support seeking into.
logger.LogDebug(
"FFmpeg returned {Bytes} bytes of audio for {Path}: {Error}",
bytes.Length,
path,
error);
return null;
}
var samples = new float[bytes.Length / sizeof(float)];
for (var i = 0; i < samples.Length; i++)
{
samples[i] = BinaryPrimitives.ReadSingleLittleEndian(bytes.AsSpan(i * sizeof(float)));
}
return samples;
}
finally
{
if (!process.HasExited)
{
try
{
process.Kill(entireProcessTree: true);
}
catch (InvalidOperationException)
{
// Exited between the check and the kill.
}
}
}
}
private static async Task<byte[]> ReadWindowAsync(Stream stream, CancellationToken cancellationToken)
{
var wanted = AudioSignature.WindowSamples * sizeof(float);
var buffer = new byte[wanted];
var filled = 0;
while (filled < wanted)
{
var read = await stream.ReadAsync(buffer.AsMemory(filled, wanted - filled), cancellationToken)
.ConfigureAwait(false);
if (read == 0)
{
break;
}
filled += read;
}
if (filled == wanted)
{
return buffer;
}
var truncated = new byte[filled];
Array.Copy(buffer, truncated, filled);
return truncated;
}
}
@@ -0,0 +1,175 @@
using System;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Runtime.Loader;
using System.Text.Json;
using Jellyfin.Plugin.JRay.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Registers JRay's overlay script with the
/// <see href="https://github.com/IAmParadox27/jellyfin-plugin-file-transformation">File Transformation</see>
/// plugin, which rewrites <c>index.html</c> as it is served instead of
/// modifying the file on disk. This is non-destructive and composes with
/// other plugins that patch the same file.
/// </summary>
/// <remarks>
/// File Transformation is referenced by reflection (rather than a NuGet
/// package reference) so JRay still loads when it isn't installed. JRay must
/// never bundle the assembly: a bundled copy would sit in a different
/// <c>AssemblyLoadContext</c> from the real one, which is precisely the failure
/// the reflection integration exists to avoid.
///
/// This is the only route by which JRay reaches the web client. There is no
/// on-disk fallback (JR-021), so when registration fails the overlay is simply
/// disabled — which is why both failure paths log a warning naming the missing
/// plugin rather than quietly degrading.
/// </remarks>
// TRACES: JR-020, JR-023 | PR-004
public static class FileTransformationRegistration
{
/// <summary>
/// The marker comment written alongside the injected script tag, used to
/// keep the transformation idempotent.
/// </summary>
internal const string Marker = "<!-- jray-overlay -->";
/// <summary>
/// Repository manifest an admin adds to install the dependency. Surfaced on
/// the configuration page rather than only in the log, since that is where
/// it can be acted on.
/// </summary>
public const string ManifestUrl = "https://www.iamparadox.dev/jellyfin/plugins/manifest.json";
private const string PluginInterfaceTypeName = "Jellyfin.Plugin.FileTransformation.PluginInterface";
private const string RegisterMethodName = "RegisterTransformation";
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
private const string BodyClose = "</body>";
/// <summary>
/// Stable id for JRay's index.html transformation. File Transformation
/// keys registrations on this, so re-registering replaces rather than
/// duplicates.
/// </summary>
private static readonly Guid TransformationId = Guid.Parse("2c9b5a41-6ad0-4c1e-9f7d-1d1e6b0d5a90");
/// <summary>
/// Attempts to register JRay's index.html transformation with the File
/// Transformation plugin.
/// </summary>
/// <param name="logger">The logger.</param>
/// <returns><see langword="true"/> if the transformation was registered;
/// <see langword="false"/> if the File Transformation plugin is not
/// installed or its interface could not be invoked.</returns>
public static bool TryRegister(ILogger logger)
{
ArgumentNullException.ThrowIfNull(logger);
try
{
var registerMethod = ResolveRegisterMethod();
if (registerMethod is null)
{
logger.LogWarning(
"JRay: the File Transformation plugin was not found, so the pause overlay is "
+ "disabled. JRay does not modify index.html on disk and has no fallback. "
+ "Install it from {ManifestUrl} to enable the overlay; every other JRay "
+ "feature is unaffected.",
ManifestUrl);
return false;
}
var payload = BuildPayload(registerMethod);
registerMethod.Invoke(null, [payload]);
logger.LogInformation("JRay: registered index.html transformation with the File Transformation plugin.");
return true;
}
catch (Exception ex) when (ex is TargetInvocationException or InvalidOperationException or JsonException or MissingMethodException)
{
logger.LogWarning(
ex,
"JRay: the File Transformation plugin is present but registration failed, so the "
+ "pause overlay is disabled. JRay does not modify index.html on disk and has no "
+ "fallback. Every other JRay feature is unaffected.");
return false;
}
}
/// <summary>
/// The transformation callback invoked by the File Transformation plugin.
/// It is resolved by name via reflection, so the signature (public,
/// static, single payload parameter, returns <see cref="string"/>) must
/// not change.
/// </summary>
/// <param name="payload">The current state of the file being served.</param>
/// <returns>The transformed file contents.</returns>
public static string TransformIndexHtml(TransformationPayload payload)
{
ArgumentNullException.ThrowIfNull(payload);
var contents = payload.Contents ?? string.Empty;
if (!Plugin.OverlayEnabled || contents.Contains(Marker, StringComparison.Ordinal))
{
return contents;
}
var index = contents.LastIndexOf(BodyClose, StringComparison.OrdinalIgnoreCase);
if (index < 0)
{
return contents;
}
return contents[..index]
+ ScriptTag
+ Marker
+ "\n"
+ contents[index..];
}
private static MethodInfo? ResolveRegisterMethod()
{
var assembly = AssemblyLoadContext.All
.SelectMany(context => context.Assemblies)
.FirstOrDefault(candidate => candidate.FullName?.Contains(".FileTransformation", StringComparison.Ordinal) ?? false);
return assembly?
.GetType(PluginInterfaceTypeName)?
.GetMethod(RegisterMethodName, BindingFlags.Public | BindingFlags.Static);
}
/// <summary>
/// Builds the registration payload. File Transformation expects a
/// Newtonsoft <c>JObject</c>, which JRay does not reference, so the
/// payload is serialized to JSON and parsed back through the type the
/// target method actually declares.
/// </summary>
private static object BuildPayload(MethodInfo registerMethod)
{
// File Transformation matches this against Assembly.FullName exactly,
// so it must be the full display name, not the short name.
var assemblyName = typeof(FileTransformationRegistration).Assembly.FullName
?? throw new InvalidOperationException("JRay assembly has no name.");
var json = JsonSerializer.Serialize(new
{
id = TransformationId.ToString("D", CultureInfo.InvariantCulture),
fileNamePattern = "index.html",
callbackAssembly = assemblyName,
callbackClass = typeof(FileTransformationRegistration).FullName,
callbackMethod = nameof(TransformIndexHtml)
});
var payloadType = registerMethod.GetParameters().FirstOrDefault()?.ParameterType
?? throw new InvalidOperationException("RegisterTransformation has no parameters.");
var parseMethod = payloadType.GetMethod("Parse", BindingFlags.Public | BindingFlags.Static, [typeof(string)])
?? throw new InvalidOperationException($"Cannot construct File Transformation payload of type '{payloadType.FullName}'.");
return parseMethod.Invoke(null, [json])
?? throw new InvalidOperationException("File Transformation payload parsed to null.");
}
}
@@ -25,9 +25,21 @@ public interface IManagedTruthStore
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <param name="truth">The truth file contents to persist.</param>
/// <param name="provenance">How this truth data was obtained.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>A task that completes when the file has been written.</returns>
Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken);
/// <remarks>
/// Provenance is written beside the truth file, never into it: the bytes
/// served back must be the bytes the producer wrote (JR-004).
/// </remarks>
Task SaveAsync(Guid itemId, TruthFile truth, TruthProvenance provenance, CancellationToken cancellationToken);
/// <summary>
/// Loads the provenance recorded alongside an item's managed truth data.
/// </summary>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <returns>The provenance, or null if this item has no managed truth data.</returns>
TruthProvenance? LoadProvenance(Guid itemId);
/// <summary>
/// Deletes the managed truth file for the given item, if one exists.
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Fetches actor-timeline manifests from the configured public servers.
/// </summary>
/// <remarks>
/// Satisfies <c>JRay-public-server</c> UR-007: the plugin queries a configurable,
/// <b>ordered</b> list of servers, and the first result clearing the configured
/// match tier wins.
/// </remarks>
// TRACES: JR-025 | PR-006
public interface IManifestExchangeClient
{
/// <summary>
/// Fetches a movie manifest from the first server that has an acceptable one.
/// </summary>
/// <param name="servers">The configured servers, in trust order.</param>
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
/// <param name="query">Identity and cut parameters for the local item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The accepted manifest, or null when no server had one.</returns>
Task<ManifestFetchOutcome?> FetchMovieAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Fetches a single episode manifest.
/// </summary>
/// <param name="servers">The configured servers, in trust order.</param>
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
/// <param name="query">Identity and cut parameters for the local item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The accepted manifest, or null when no server had one.</returns>
Task<ManifestFetchOutcome?> FetchEpisodeAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Per-server reachability and last error, for the configuration page.
/// </summary>
/// <param name="servers">The configured servers.</param>
/// <returns>One status entry per configured server.</returns>
IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> servers);
}
@@ -0,0 +1,34 @@
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Stores the prioritise/ignore rules that shape the work-discovery API
/// (<c>GET /Plugins/JRay/Tasks/Pending</c>). Rules can target a genre, a
/// series, or a single item; more specific scopes override broader ones.
/// </summary>
public interface IMediaPolicyStore
{
/// <summary>
/// Gets all currently configured rules.
/// </summary>
/// <returns>The list of rules (a copy; safe to enumerate).</returns>
IReadOnlyList<MediaPolicyRule> GetRules();
/// <summary>
/// Adds or replaces the rule for a given scope+value. If a rule already
/// exists for the same scope and value, its action and label are updated,
/// so a target can never hold two conflicting actions.
/// </summary>
/// <param name="rule">The rule to set.</param>
void SetRule(MediaPolicyRule rule);
/// <summary>
/// Removes the rule matching the given scope and value, if present.
/// </summary>
/// <param name="scope">The scope of the rule to remove.</param>
/// <param name="value">The value of the rule to remove.</param>
/// <returns><c>true</c> if a rule was removed.</returns>
bool RemoveRule(PolicyScope scope, string value);
}
@@ -18,6 +18,17 @@ public interface ITruthDataService
/// <returns>The parsed truth file, or null if no truth file exists for this item.</returns>
Task<TruthFile?> GetTruthAsync(Guid itemId, CancellationToken cancellationToken);
/// <summary>
/// Gets how the item's truth data was obtained, or null if it has none.
/// </summary>
/// <remarks>
/// Resolves the same precedence as <see cref="GetTruthAsync"/>: managed
/// truth (pushed or fetched) wins, and a sidecar is reported as such.
/// </remarks>
/// <param name="itemId">The Jellyfin library item id.</param>
/// <returns>The provenance of the truth data that would be served.</returns>
TruthProvenance? GetProvenance(Guid itemId);
/// <summary>
/// Removes any cached truth file for the given item, so the next
/// <see cref="GetTruthAsync"/> call re-reads from the managed store or sidecar file.
@@ -14,6 +14,11 @@ namespace Jellyfin.Plugin.JRay.Services;
/// Stores truth files pushed directly to JRay (e.g. by a remote extraction
/// worker) under the plugin's configuration directory, keyed by item id.
/// </summary>
/// <remarks>
/// Deliberately outside the media library filesystem, so a worker that cannot
/// write beside the media file is not a second-class producer.
/// </remarks>
// TRACES: JR-009, JR-010 | PR-004
public sealed class ManagedTruthStore : IManagedTruthStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -44,18 +49,36 @@ public sealed class ManagedTruthStore : IManagedTruthStore
try
{
using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
// Managed truth is checked on the way out as well as on the way in.
// Data written by an earlier plugin version is already on disk, and
// it did not pass today's PUT.
if (!TruthSchema.IsSupported(truth))
{
_logger.LogWarning(
"JRay: ignoring managed truth {Path} — schema_version {Found}, expected {Expected}. Re-push or re-extract this item.",
path,
truth?.SchemaVersion ?? 0,
TruthSchema.SupportedVersion);
return null;
}
return truth;
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read managed truth file {Path}", path);
_logger.LogWarning(
ex,
"JRay: failed to read managed truth file {Path}. If this is v1 data, re-push it — v1 is no longer read.",
path);
return null;
}
}
/// <inheritdoc />
public async Task SaveAsync(Guid itemId, TruthFile truth, CancellationToken cancellationToken)
public async Task SaveAsync(Guid itemId, TruthFile truth, TruthProvenance provenance, CancellationToken cancellationToken)
{
var path = GetPath(itemId);
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Managed truth path has no directory.");
@@ -68,11 +91,52 @@ public sealed class ManagedTruthStore : IManagedTruthStore
}
File.Move(tempPath, path, overwrite: true);
// Written after the truth file, so a crash between the two leaves truth
// with no provenance (readable, source unknown) rather than provenance
// describing a file that is not there.
var provenancePath = GetProvenancePath(itemId);
var provenanceTemp = provenancePath + ".tmp";
using (var stream = File.Create(provenanceTemp))
{
await JsonSerializer.SerializeAsync(stream, provenance, JsonOptions, cancellationToken).ConfigureAwait(false);
}
File.Move(provenanceTemp, provenancePath, overwrite: true);
}
/// <inheritdoc />
public TruthProvenance? LoadProvenance(Guid itemId)
{
var path = GetProvenancePath(itemId);
if (!File.Exists(path))
{
return null;
}
try
{
using var stream = File.OpenRead(path);
return JsonSerializer.Deserialize<TruthProvenance>(stream, JsonOptions);
}
catch (Exception ex) when (ex is IOException or JsonException)
{
// Provenance is metadata about the claim, not the claim. Losing it
// must never make readable truth data unreadable.
_logger.LogWarning(ex, "JRay: failed to read truth provenance at {Path}", path);
return null;
}
}
/// <inheritdoc />
public bool Delete(Guid itemId)
{
var provenancePath = GetProvenancePath(itemId);
if (File.Exists(provenancePath))
{
File.Delete(provenancePath);
}
var path = GetPath(itemId);
if (!File.Exists(path))
{
@@ -93,4 +157,9 @@ public sealed class ManagedTruthStore : IManagedTruthStore
{
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".json");
}
private string GetProvenancePath(Guid itemId)
{
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "truth", itemId.ToString("D") + ".provenance.json");
}
}
@@ -0,0 +1,205 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Aligns a freshly fetched manifest to the local file before its windows are
/// stored.
/// </summary>
/// <remarks>
/// <b>Why the client re-derives an offset the server already sent.</b> The
/// server has never seen the local file. Its offset is a claim about a runtime
/// it was told, so it can only ever be a runtime-difference inference. A local
/// alignment compares the manifest's own audio signature against the file the
/// windows will actually be drawn over, which is the authoritative comparison —
/// and it needs no round trip, so no signature leaves the instance. Where the
/// two disagree, the local one wins.
/// <para>
/// This is what jRay's specification means by matching being "a consumer
/// concern": the server never rewrites a manifest, so one stored manifest serves
/// every trim of the same cut, and each client shifts it to its own timebase
/// (JR-030).
/// </para>
/// <para>
/// <b>Degradation, not failure.</b> Signatures switched off, an item under the
/// 120 s window, a manifest with no signature, a missing FFmpeg binary, a decode
/// error, or two signatures that simply do not match — every one of these falls
/// back to the server's offset. A signature is an enhancement to cut matching; a
/// missing one costs a tier and must never be able to break a fetch.
/// </para>
/// </remarks>
// TRACES: JR-047 | SR-003
public class ManifestAligner
{
private readonly AudioSignatureService _signatures;
private readonly ILogger<ManifestAligner> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ManifestAligner"/> class.
/// </summary>
/// <param name="signatures">Computes the local file's audio signature.</param>
/// <param name="logger">Logger.</param>
public ManifestAligner(AudioSignatureService signatures, ILogger<ManifestAligner> logger)
{
_signatures = signatures;
_logger = logger;
}
/// <summary>
/// Decides which offset to apply, given a local signature that has already
/// been computed.
/// </summary>
/// <remarks>
/// Split from <see cref="AlignAsync"/> so the decision is testable without
/// an FFmpeg binary or a media file: everything interesting happens here,
/// and the caller only supplies the two strings.
/// </remarks>
/// <param name="localSignature">The local file's signature, or <c>null</c>.</param>
/// <param name="manifestSignature">The manifest's signature, or <c>null</c>.</param>
/// <param name="localRuntimeSec">Local file runtime, in seconds.</param>
/// <param name="manifestRuntimeSec">Runtime the manifest records, in seconds.</param>
/// <param name="serverTier">The tier the server reported.</param>
/// <param name="serverOffsetSec">The offset the server reported.</param>
/// <returns>What to apply, and how it was decided.</returns>
public static TruthAlignment Resolve(
string? localSignature,
string? manifestSignature,
double localRuntimeSec,
double manifestRuntimeSec,
MatchTier serverTier,
double serverOffsetSec)
{
var alignment = new TruthAlignment
{
Source = AlignmentSource.Server,
Tier = serverTier,
OffsetSec = serverOffsetSec,
ServerTier = serverTier,
ServerOffsetSec = serverOffsetSec,
LocalSignature = localSignature,
};
// "No alignment was possible" and "the audio does not match" are very
// different things to tell someone, and `Compare` returns null for both.
// A comparison is only possible when both items clear the 120 s window
// (JR-044) and both signatures parse as v1 (JR-045) — a `v2:` signature
// from a future producer is un-comparable, not a mismatch. Separating
// them here is what keeps a 90-second extra from being reported as
// content that disagrees with its own manifest.
var comparable = localSignature is not null
&& manifestSignature is not null
&& localRuntimeSec >= AudioSignature.WindowSec
&& manifestRuntimeSec >= AudioSignature.WindowSec
&& AudioSignatureMatcher.TryParseFrames(localSignature) is not null
&& AudioSignatureMatcher.TryParseFrames(manifestSignature) is not null;
if (!comparable)
{
return alignment;
}
var match = AudioSignatureMatcher.Compare(
localSignature, manifestSignature, localRuntimeSec, manifestRuntimeSec);
if (match is null)
{
// Both sides had a signature and they did not align at any tier. The
// server's offset still applies — the audio may legitimately differ,
// and a signature must not break a fetch — but this is the strongest
// available hint that the manifest describes different content, so
// it is recorded rather than silently discarded.
alignment.Source = AlignmentSource.LocalMismatch;
return alignment;
}
alignment.Source = AlignmentSource.Local;
alignment.Tier = match.Value.Tier;
alignment.OffsetSec = match.Value.OffsetSec;
alignment.Score = match.Value.Score;
alignment.OffsetFrames = match.Value.OffsetFrames;
return alignment;
}
/// <summary>
/// Computes the local file's signature if it can, then resolves the
/// alignment.
/// </summary>
/// <param name="mediaPath">Path of the local media file.</param>
/// <param name="localRuntimeSec">Local file runtime, in seconds.</param>
/// <param name="manifest">The fetched manifest.</param>
/// <param name="serverTier">The tier the server reported.</param>
/// <param name="serverOffsetSec">The offset the server reported.</param>
/// <param name="computeSignatures">Whether signatures are enabled in configuration.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>What to apply, and how it was decided.</returns>
public async Task<TruthAlignment> AlignAsync(
string mediaPath,
double localRuntimeSec,
Jmanifest manifest,
MatchTier serverTier,
double serverOffsetSec,
bool computeSignatures,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(manifest);
var manifestSignature = manifest.Cut?.AudioSignature;
string? localSignature = null;
// The decode is the expensive half, so it is skipped outright when it
// could not change the answer: no manifest signature to compare against
// means no local alignment is possible.
if (computeSignatures && manifestSignature is not null && !string.IsNullOrEmpty(mediaPath))
{
localSignature = await _signatures
.ComputeAsync(mediaPath, localRuntimeSec, cancellationToken)
.ConfigureAwait(false);
}
var alignment = Resolve(
localSignature,
manifestSignature,
localRuntimeSec,
manifest.Cut?.RuntimeSec ?? 0.0,
serverTier,
serverOffsetSec);
switch (alignment.Source)
{
case AlignmentSource.Local:
_logger.LogInformation(
"Local audio alignment for {Path}: score {Score:F3}, {Frames} frames, offset {Offset:F3}s at tier {Tier} (server said {ServerOffset:F3}s at {ServerTier})",
mediaPath,
alignment.Score,
alignment.OffsetFrames,
alignment.OffsetSec,
alignment.Tier,
alignment.ServerOffsetSec,
alignment.ServerTier);
break;
case AlignmentSource.LocalMismatch:
_logger.LogWarning(
"Audio signatures for {Path} did not align with the fetched manifest; applying the server's {Offset:F3}s at {Tier}. This may be a different cut, a different language track, or a heavy re-encode.",
mediaPath,
alignment.OffsetSec,
alignment.Tier);
break;
default:
_logger.LogDebug(
"No local alignment for {Path}; applying the server's {Offset:F3}s at {Tier}",
mediaPath,
alignment.OffsetSec,
alignment.Tier);
break;
}
return alignment;
}
}
@@ -0,0 +1,148 @@
using System;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Converts a fetched manifest into the truth file the plugin stores.
/// </summary>
// TRACES: JR-030 | SR-002, SR-003
public static class ManifestConverter
{
/// <summary>
/// Builds a truth file from a manifest, shifting every window by
/// <paramref name="offsetSec"/>.
/// </summary>
/// <remarks>
/// <b>The offset is applied here, once, at store time.</b> The server returns
/// it and the client applies it, so a single stored manifest serves every
/// trim of the same cut without ever being rewritten upstream. Applying it on
/// the way in means the stored truth is always in the local file's own
/// timebase, so the overlay and the <c>jray?t=</c> query need no offset
/// awareness at read time — the alternative would put the same correction in
/// every reader, forever, and one of them would eventually forget.
/// <para>
/// Windows are shifted, never reshaped: a window is a claim about scene
/// membership (SR-002), so merging or trimming would answer a different
/// question than the one the extraction pipeline answered.
/// </para>
/// </remarks>
/// <param name="manifest">The validated manifest.</param>
/// <param name="offsetSec">Seconds to add to every window.</param>
/// <param name="mediaPath">Local media path, recorded informationally.</param>
/// <returns>The truth file to store.</returns>
public static TruthFile ToTruthFile(Jmanifest manifest, double offsetSec, string mediaPath)
{
ArgumentNullException.ThrowIfNull(manifest);
var truth = new TruthFile
{
SchemaVersion = TruthSchema.SupportedVersion,
Movie = mediaPath ?? string.Empty,
};
// Provenance is carried across rather than flattened. The two blocks
// have the same shape by design (JR-002), so anything the server knew
// about how a manifest was produced survives into the stored truth.
if (manifest.Extraction is { } extraction)
{
truth.Extraction = new TruthExtraction
{
SampleFps = extraction.SampleFps,
ExtinctionSec = extraction.ExtinctionSec,
PipelineVersion = extraction.PipelineVersion,
GallerySize = extraction.GallerySize,
GalleryScope = extraction.GalleryScope,
};
}
// The cut is the *manifest's*, not the local file's: it records the
// encode the timings were measured against, which is what makes the
// applied offset interpretable later. Recording the local runtime here
// instead would erase the very discrepancy the offset corrects.
if (manifest.Cut is { } cut)
{
truth.Cut = new TruthCut
{
RuntimeSec = cut.RuntimeSec,
AudioSignature = cut.AudioSignature,
};
}
foreach (var actor in manifest.Actors)
{
var converted = new TruthActor
{
Name = actor.Name ?? string.Empty,
ImdbId = actor.ImdbId ?? string.Empty,
TmdbId = actor.TmdbId ?? string.Empty,
};
foreach (var scene in actor.Scenes)
{
// Clamped at zero: a negative offset on an early window would
// otherwise produce a start before the file begins, which no
// reader can index.
var start = Math.Max(0, scene.Start + offsetSec);
var end = Math.Max(start, scene.End + offsetSec);
converted.Scenes.Add(new TruthScene
{
Start = start,
End = end,
// Belief and route survive the conversion. They are what a
// consumer needs to know how far to trust a window, and
// dropping them here would silently downgrade every fetched
// manifest against a locally extracted one.
Belief = scene.Belief,
Route = scene.Route,
});
}
truth.Actors.Add(converted);
}
return truth;
}
/// <summary>
/// A short, human-readable description of how a manifest matched, for the
/// UI to show as a caveat.
/// </summary>
/// <remarks>
/// A <c>loose</c> match should surface as a caveat rather than being applied
/// silently: it means the runtimes differ by up to 30 seconds, which is
/// usually a different trim of the same cut but is not guaranteed to be.
/// </remarks>
/// <param name="tier">The tier achieved.</param>
/// <param name="offsetSec">The offset applied.</param>
/// <param name="alignment">How the offset was arrived at, when known.</param>
/// <returns>A caveat string, or null when the match needs no explanation.</returns>
public static string? DescribeCaveat(MatchTier tier, double offsetSec, TruthAlignment? alignment = null)
{
// Ranked before the tier, because it is the stronger statement: the
// server's tier says the runtimes are compatible, while a signature
// mismatch says the audio itself is not. The second outranks the first.
if (alignment?.Source == AlignmentSource.LocalMismatch)
{
return "This file's audio does not match the fetched manifest — it may be a different cut, "
+ "a different language track, or a heavy re-encode. Timings may be wrong.";
}
if (tier == MatchTier.Loose)
{
return "Matched loosely — the runtime differs from this server's copy, so timings may drift.";
}
if (Math.Abs(offsetSec) > 0.001)
{
return string.Create(
CultureInfo.InvariantCulture,
$"Matched by audio content and shifted by {offsetSec:0.##}s to align with this file.");
}
return null;
}
}
@@ -0,0 +1,448 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Fetches actor-timeline manifests from the configured servers.
/// </summary>
/// <remarks>
/// Servers are an <b>ordered list</b>, and order is the user's trust ranking made
/// explicit: for a fetch, servers are tried in order and the <i>first acceptable</i>
/// result wins — acceptable meaning it clears the configured match tier.
/// <para>
/// First-match rather than best-match is deliberate. Querying every server for
/// every item multiplies egress, leaks the library to more parties, and the
/// ordering already encodes which source the admin prefers. Each configured
/// server multiplies the privacy exposure described in the server spec §9, so
/// later servers are queried only for what earlier ones lacked.
/// </para>
/// </remarks>
// TRACES: JR-025, JR-029, JR-030, JR-037 | PR-005, PR-006
public class ManifestExchangeClient : IManifestExchangeClient, IDisposable
{
/// <summary>Server spec §9: a single manifest response is capped at 2 MiB.</summary>
public const long MaxManifestBytes = 2 * 1024 * 1024;
/// <summary>Server spec §9: a bundle response is capped at 25 MiB.</summary>
public const long MaxBundleBytes = 25L * 1024 * 1024;
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// How long a server that failed is skipped for, doubling each consecutive
/// failure. One dead server must never stall a library sweep.
/// </summary>
private static readonly TimeSpan BaseBackoff = TimeSpan.FromMinutes(1);
private static readonly TimeSpan MaxBackoff = TimeSpan.FromHours(1);
private readonly HttpClient _http;
private readonly ILogger<ManifestExchangeClient> _logger;
private readonly ConcurrentDictionary<string, ServerHealth> _health = new(StringComparer.Ordinal);
private readonly bool _ownsClient;
/// <summary>
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ManifestExchangeClient(ILogger<ManifestExchangeClient> logger)
: this(logger, null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class
/// with an injected transport, for testing.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="httpClient">Transport to use, or null to build the default.</param>
public ManifestExchangeClient(ILogger<ManifestExchangeClient> logger, HttpClient? httpClient)
{
_logger = logger;
_ownsClient = httpClient is null;
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
{
ConnectTimeout = ConnectTimeout,
// Certificate validation is never disabled: a plaintext or
// unverified server would let any network intermediary rewrite
// actor overlays.
AutomaticDecompression = System.Net.DecompressionMethods.All,
})
{
Timeout = ReadTimeout,
};
}
/// <inheritdoc />
public async Task<ManifestFetchOutcome?> FetchMovieAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(servers);
ArgumentNullException.ThrowIfNull(query);
foreach (var server in Eligible(servers))
{
var url = BuildUrl(server, "manifests/movie", query);
if (url is null)
{
continue;
}
var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
.ConfigureAwait(false);
if (outcome is not null)
{
// First acceptable result wins — no further servers are queried,
// which is what bounds the privacy exposure.
return outcome;
}
}
return null;
}
/// <inheritdoc />
public async Task<ManifestFetchOutcome?> FetchEpisodeAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(servers);
ArgumentNullException.ThrowIfNull(query);
foreach (var server in Eligible(servers))
{
var url = BuildUrl(server, "manifests/episode", query);
if (url is null)
{
continue;
}
var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
.ConfigureAwait(false);
if (outcome is not null)
{
return outcome;
}
}
return null;
}
/// <inheritdoc />
public IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> servers)
{
ArgumentNullException.ThrowIfNull(servers);
return servers.Select(s =>
{
_health.TryGetValue(s.Url, out var h);
return new ServerStatus
{
Url = s.Url,
Name = s.Name,
Enabled = s.Enabled,
Reachable = h is null || h.ConsecutiveFailures == 0,
LastError = h?.LastError,
SkippedUntil = h?.SkipUntil,
};
}).ToList();
}
/// <summary>
/// Servers that are enabled and not currently in backoff, in configured order.
/// </summary>
private IEnumerable<ManifestServer> Eligible(IReadOnlyList<ManifestServer> servers)
{
var now = DateTimeOffset.UtcNow;
foreach (var s in servers)
{
if (!s.Enabled || string.IsNullOrWhiteSpace(s.Url))
{
continue;
}
if (_health.TryGetValue(s.Url, out var h) && h.SkipUntil > now)
{
_logger.LogDebug("Skipping {Url} until {Until} after {Failures} failures", s.Url, h.SkipUntil, h.ConsecutiveFailures);
continue;
}
yield return s;
}
}
private async Task<ManifestFetchOutcome?> TryFetchOneAsync(
ManifestServer server,
Uri url,
TitleQuery query,
MatchTier minimumTier,
CancellationToken cancellationToken)
{
try
{
using var response = await _http
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Not an error: this server simply does not hold it. The next
// server in the list gets a turn.
RecordSuccess(server.Url);
return null;
}
if (!response.IsSuccessStatusCode)
{
RecordFailure(server.Url, $"HTTP {(int)response.StatusCode}");
return null;
}
var json = await ReadCappedAsync(response, MaxManifestBytes, cancellationToken)
.ConfigureAwait(false);
if (json is null)
{
RecordFailure(server.Url, "response exceeded the size cap");
return null;
}
var body = JsonSerializer.Deserialize<ManifestFetchResponse>(json);
RecordSuccess(server.Url);
if (body?.Manifest is null)
{
return null;
}
var tier = ParseTier(body.Match);
if (tier is null || tier < minimumTier)
{
_logger.LogDebug(
"{Url} matched at {Tier}, below the configured minimum {Minimum}",
server.Url,
body.Match,
minimumTier);
return null;
}
if (!ManifestValidator.TryValidate(body.Manifest, query.RuntimeSec, out var error))
{
// A manifest is never trusted merely because a server served it.
_logger.LogWarning("Rejected manifest from {Url}: {Error}", server.Url, error);
return null;
}
return new ManifestFetchOutcome
{
ServerUrl = server.Url,
Tier = tier.Value,
OffsetSec = body.OffsetSec,
Manifest = body.Manifest,
};
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
// A slow, unreachable or nonsense-returning server is skipped and
// backed off; it must never stall the sweep or fail the whole fetch.
RecordFailure(server.Url, ex.Message);
return null;
}
}
/// <summary>
/// Reads a response body, aborting once it exceeds <paramref name="cap"/>.
/// </summary>
/// <remarks>
/// Capped <b>while streaming</b> rather than after buffering: a hostile
/// server can declare any <c>Content-Length</c> it likes, so reading to
/// completion and then measuring is exactly the denial-of-service primitive
/// the cap exists to prevent.
/// </remarks>
// TRACES: JR-028 | SR-004
private static async Task<string?> ReadCappedAsync(
HttpResponseMessage response,
long cap,
CancellationToken cancellationToken)
{
// The declared length is a cheap early rejection, never the enforcement.
if (response.Content.Headers.ContentLength is { } declared && declared > cap)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var buffer = new byte[8192];
using var accumulated = new System.IO.MemoryStream();
while (true)
{
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
break;
}
if (accumulated.Length + read > cap)
{
return null;
}
await accumulated.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
}
return System.Text.Encoding.UTF8.GetString(accumulated.ToArray());
}
/// <summary>
/// Builds a fetch URL, or null when the server's URL is unusable.
/// </summary>
/// <remarks>
/// <b>HTTPS is required for anything that is not loopback.</b> A plaintext
/// community server would let any network intermediary rewrite actor
/// overlays, and the overlay is displayed to the user as fact.
/// </remarks>
/// <param name="server">The configured server.</param>
/// <param name="path">API path below <c>/api/v1/</c>.</param>
/// <param name="query">Identity and cut parameters.</param>
/// <returns>The URL to request, or null when the server URL is unusable.</returns>
internal static Uri? BuildUrl(ManifestServer server, string path, TitleQuery query)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(query);
if (!Uri.TryCreate(server.Url, UriKind.Absolute, out var baseUri))
{
return null;
}
if (!IsTransportAcceptable(baseUri))
{
return null;
}
var q = query.ToQueryString();
var trimmed = baseUri.AbsoluteUri.TrimEnd('/');
return Uri.TryCreate($"{trimmed}/api/v1/{path}?{q}", UriKind.Absolute, out var built)
? built
: null;
}
/// <summary>
/// True when the URL may be used: HTTPS anywhere, or HTTP on loopback only.
/// </summary>
/// <param name="uri">The server base URL.</param>
/// <returns><c>true</c> when the transport is acceptable.</returns>
internal static bool IsTransportAcceptable(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (uri.Scheme == Uri.UriSchemeHttps)
{
return true;
}
if (uri.Scheme != Uri.UriSchemeHttp)
{
return false;
}
// Loopback is exempt because there is no network path to intercept.
return uri.IsLoopback;
}
/// <summary>Parses a tier name the server reported.</summary>
/// <param name="tier">The tier string.</param>
/// <returns>The tier, or null when unrecognised.</returns>
internal static MatchTier? ParseTier(string? tier) => tier switch
{
// `exact` is deliberately absent: the file-hash tier is withdrawn on
// legal grounds (see MatchTier). A server cannot report it to us anyway,
// since we send no `video_hash` — and if one did, treating it as
// unrecognised means the manifest is declined rather than silently
// accepted under a tier this plugin has no policy for.
"audio" => MatchTier.Audio,
"runtime" => MatchTier.Runtime,
"loose" => MatchTier.Loose,
_ => null,
};
private void RecordSuccess(string url) => _health.TryRemove(url, out _);
private void RecordFailure(string url, string error)
{
var updated = _health.AddOrUpdate(
url,
_ => new ServerHealth { ConsecutiveFailures = 1, LastError = error, SkipUntil = DateTimeOffset.UtcNow + BaseBackoff },
(_, existing) =>
{
var failures = existing.ConsecutiveFailures + 1;
// Exponential, capped: a server that is down for a day should
// not be retried every minute for that whole day.
var delayTicks = Math.Min(
BaseBackoff.Ticks * (long)Math.Pow(2, Math.Min(failures - 1, 6)),
MaxBackoff.Ticks);
return new ServerHealth
{
ConsecutiveFailures = failures,
LastError = error,
SkipUntil = DateTimeOffset.UtcNow + TimeSpan.FromTicks(delayTicks),
};
});
_logger.LogWarning(
"Server {Url} failed ({Failures} consecutive): {Error}. Skipping until {Until}",
url,
updated.ConsecutiveFailures,
error,
updated.SkipUntil);
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the transport when this instance created it.
/// </summary>
/// <param name="disposing">Whether managed resources should be released.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing && _ownsClient)
{
_http.Dispose();
}
}
private sealed class ServerHealth
{
public int ConsecutiveFailures { get; init; }
public string? LastError { get; init; }
public DateTimeOffset SkipUntil { get; init; }
}
}
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>A manifest accepted from a server.</summary>
public class ManifestFetchOutcome
{
/// <summary>Gets or sets which server supplied it.</summary>
public string ServerUrl { get; set; } = string.Empty;
/// <summary>Gets or sets the cut-match tier achieved.</summary>
public MatchTier Tier { get; set; }
/// <summary>Gets or sets the offset the client must apply to every window.</summary>
public double OffsetSec { get; set; }
/// <summary>Gets or sets the manifest.</summary>
public Jmanifest? Manifest { get; set; }
}
@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Re-validates a manifest received from a server.
/// </summary>
/// <remarks>
/// <b>Every server is untrusted, including the pre-configured community one.</b>
/// Everything the server specification guarantees is a property of a *correctly
/// operated* server; pointing the plugin at an arbitrary URL inherits none of
/// it. So the plugin re-applies client-side what the server applies on upload:
/// unknown-shaped data rejected, identifiers format-checked, windows
/// bounds-checked against the item's real runtime.
/// <para>
/// The honest framing for the configuration page is that adding a third-party
/// server means trusting its operator not to serve you deliberately wrong actor
/// data. These checks bound the damage to bad overlay content; they cannot make
/// wrong data right.
/// </para>
/// </remarks>
// TRACES: JR-027 | SR-004
public static class ManifestValidator
{
/// <summary>The exchange envelope version this plugin speaks (SR-003).</summary>
public const int SupportedJmanifestVersion = 2;
/// <summary>Server spec §6: no more than 500 actors in one manifest.</summary>
public const int MaxActors = 500;
/// <summary>Server spec §6: no more than 2000 windows for one actor.</summary>
public const int MaxScenesPerActor = 2000;
/// <summary>Server spec §6: no more than 20000 windows in total.</summary>
public const int MaxTotalScenes = 20000;
/// <summary>Server spec §6: names are capped at 200 characters.</summary>
public const int MaxNameLength = 200;
/// <summary>
/// Windows may exceed the measured runtime by this much before being
/// rejected, covering rounding and container-duration disagreement.
/// </summary>
public const double RuntimeToleranceSec = 5.0;
/// <summary>
/// Validates a manifest against the local item's measured runtime.
/// </summary>
/// <param name="manifest">The manifest as received.</param>
/// <param name="localRuntimeSec">
/// The runtime of the local file, or null when it is not known. Windows are
/// bounds-checked against it when it is available.
/// </param>
/// <param name="error">The first problem found, naming the offending field.</param>
/// <returns><c>true</c> when the manifest is safe to store.</returns>
public static bool TryValidate(Jmanifest? manifest, double? localRuntimeSec, out string error)
{
if (manifest is null)
{
error = "manifest: absent";
return false;
}
// An unknown envelope version is refused, never guessed at (JR-003).
// A server one version ahead may have changed the meaning of a field
// this plugin thinks it understands.
if (manifest.JmanifestVersion != SupportedJmanifestVersion)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"jmanifest_version: unsupported version {manifest.JmanifestVersion}, expected {SupportedJmanifestVersion}");
return false;
}
if (manifest.Identity is null)
{
error = "identity: absent";
return false;
}
if (manifest.Cut is null || !IsSaneRuntime(manifest.Cut.RuntimeSec))
{
error = "cut.runtime_sec: absent or not a plausible duration";
return false;
}
if (manifest.Actors.Count == 0)
{
error = "actors: empty";
return false;
}
if (manifest.Actors.Count > MaxActors)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors: more than {MaxActors} entries");
return false;
}
// Bounds are checked against the *local* file where known, because that
// is what the overlay will index into. A window past the end of the file
// is not merely useless, it is evidence the manifest is for another cut.
var limit = (localRuntimeSec ?? manifest.Cut.RuntimeSec) + RuntimeToleranceSec;
var total = 0;
var seenTmdb = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < manifest.Actors.Count; i++)
{
var actor = manifest.Actors[i];
if (actor.Name is { Length: > MaxNameLength })
{
error = string.Create(CultureInfo.InvariantCulture, $"actors[{i}].name: too long");
return false;
}
if (actor.Name is not null && ContainsControlCharacters(actor.Name))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].name: contains control characters");
return false;
}
if (actor.TmdbId is { Length: > 0 } tmdb)
{
if (!IsDigits(tmdb, 9))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].tmdb_id: not a TMDB id");
return false;
}
if (!seenTmdb.Add(tmdb))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].tmdb_id: duplicate actor");
return false;
}
}
if (actor.ImdbId is { Length: > 0 } imdb && !IsPersonImdbId(imdb))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].imdb_id: not an IMDB person id");
return false;
}
if (actor.Scenes.Count > MaxScenesPerActor)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes: more than {MaxScenesPerActor} entries");
return false;
}
total += actor.Scenes.Count;
if (total > MaxTotalScenes)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors: more than {MaxTotalScenes} windows in total");
return false;
}
for (var j = 0; j < actor.Scenes.Count; j++)
{
var scene = actor.Scenes[j];
if (!IsFinite(scene.Start) || !IsFinite(scene.End))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}]: non-finite value");
return false;
}
if (scene.Start < 0 || scene.End < scene.Start)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}]: negative or inverted window");
return false;
}
if (scene.End > limit)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}]: ends beyond the item's runtime");
return false;
}
// A posterior outside [0, 1] is not a probability.
if (scene.Belief is { } b && (!IsFinite(b) || b < 0 || b > 1))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}].belief: outside [0, 1]");
return false;
}
}
}
error = string.Empty;
return true;
}
private static bool IsSaneRuntime(double v) => IsFinite(v) && v > 0 && v < 200_000;
private static bool IsFinite(double v) => !double.IsNaN(v) && !double.IsInfinity(v);
private static bool IsDigits(string s, int maxLength) =>
s.Length > 0 && s.Length <= maxLength && s.All(char.IsAsciiDigit);
private static bool IsPersonImdbId(string s) =>
s.StartsWith("nm", StringComparison.Ordinal)
&& (s.Length == 9 || s.Length == 10)
&& s.AsSpan(2).ToString().All(char.IsAsciiDigit);
/// <summary>
/// Control characters are refused outright. The overlay renders names as
/// text nodes (JR-024), so markup is already inert, but a bidi override or a
/// zero-width joiner can still make a name display as something other than
/// what was stored.
/// </summary>
private static bool ContainsControlCharacters(string s)
{
foreach (var c in s)
{
if (char.IsControl(c))
{
return true;
}
// Zero-width and bidi-control codepoints.
if (c is >= '' and <= ''
or >= '' and <= ''
or >= '' and <= ''
or '')
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,140 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Persists the prioritise/ignore rules to a single JSON file under the
/// plugin's configuration directory, and keeps an in-memory copy for fast
/// reads. All access is synchronised so the work-discovery API and the
/// config page can touch it concurrently.
/// </summary>
// TRACES: JR-016 | PR-003
public sealed class MediaPolicyStore : IMediaPolicyStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly IApplicationPaths _applicationPaths;
private readonly ILogger<MediaPolicyStore> _logger;
private readonly object _gate = new();
private List<MediaPolicyRule>? _rules;
/// <summary>
/// Initializes a new instance of the <see cref="MediaPolicyStore"/> class.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="logger">The logger.</param>
public MediaPolicyStore(IApplicationPaths applicationPaths, ILogger<MediaPolicyStore> logger)
{
_applicationPaths = applicationPaths;
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<MediaPolicyRule> GetRules()
{
lock (_gate)
{
return EnsureLoaded().ToList();
}
}
/// <inheritdoc />
public void SetRule(MediaPolicyRule rule)
{
ArgumentNullException.ThrowIfNull(rule);
lock (_gate)
{
var rules = EnsureLoaded();
rules.RemoveAll(r => r.Scope == rule.Scope && ValueEquals(r.Value, rule.Value));
rules.Add(rule);
Save(rules);
}
}
/// <inheritdoc />
public bool RemoveRule(PolicyScope scope, string value)
{
lock (_gate)
{
var rules = EnsureLoaded();
var removed = rules.RemoveAll(r => r.Scope == scope && ValueEquals(r.Value, value));
if (removed > 0)
{
Save(rules);
return true;
}
return false;
}
}
private static bool ValueEquals(string a, string b)
{
// Genre names are matched case-insensitively; ids happen to be
// case-insensitive too (GUID strings), so a single comparison suffices.
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
}
private List<MediaPolicyRule> EnsureLoaded()
{
if (_rules is not null)
{
return _rules;
}
var path = GetPath();
if (!File.Exists(path))
{
_rules = new List<MediaPolicyRule>();
return _rules;
}
try
{
using var stream = File.OpenRead(path);
_rules = JsonSerializer.Deserialize<List<MediaPolicyRule>>(stream, JsonOptions)
?? new List<MediaPolicyRule>();
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read media policy file {Path}; starting empty", path);
_rules = new List<MediaPolicyRule>();
}
return _rules;
}
private void Save(List<MediaPolicyRule> rules)
{
var path = GetPath();
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Media policy path has no directory.");
Directory.CreateDirectory(directory);
var tempPath = path + ".tmp";
using (var stream = File.Create(tempPath))
{
JsonSerializer.Serialize(stream, rules, JsonOptions);
}
File.Move(tempPath, path, overwrite: true);
}
private string GetPath()
{
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "policy.json");
}
}
@@ -0,0 +1,85 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Resolves the effective prioritise/ignore action for an item from the
/// configured rules. More specific scopes win: an Item rule overrides a
/// Series rule, which overrides a Genre rule. Because a given scope+value can
/// hold only one action, the only conflicts possible are across scopes, and
/// specificity resolves those.
/// </summary>
// TRACES: JR-016 | PR-003
public static class PolicyResolver
{
/// <summary>
/// Computes the effective action for an item, or <c>null</c> if no rule matches.
/// </summary>
/// <param name="rules">The configured rules.</param>
/// <param name="itemId">The item's id.</param>
/// <param name="seriesId">The item's series id, or <see cref="Guid.Empty"/> if it is not an episode.</param>
/// <param name="genres">The item's genres.</param>
/// <returns>The effective <see cref="PolicyAction"/>, or <c>null</c> when unruled.</returns>
public static PolicyAction? Resolve(
IReadOnlyList<MediaPolicyRule> rules,
Guid itemId,
Guid seriesId,
IReadOnlyList<string> genres)
{
ArgumentNullException.ThrowIfNull(rules);
PolicyAction? itemAction = null;
PolicyAction? seriesAction = null;
PolicyAction? genreAction = null;
var itemIdStr = itemId.ToString("D");
var seriesIdStr = seriesId == Guid.Empty ? null : seriesId.ToString("D");
foreach (var rule in rules)
{
switch (rule.Scope)
{
case PolicyScope.Item:
if (string.Equals(rule.Value, itemIdStr, StringComparison.OrdinalIgnoreCase))
{
itemAction = rule.Action;
}
break;
case PolicyScope.Series:
if (seriesIdStr is not null && string.Equals(rule.Value, seriesIdStr, StringComparison.OrdinalIgnoreCase))
{
seriesAction = rule.Action;
}
break;
case PolicyScope.Genre:
if (genres is not null && MatchesGenre(genres, rule.Value))
{
genreAction = rule.Action;
}
break;
}
}
return itemAction ?? seriesAction ?? genreAction;
}
private static bool MatchesGenre(IReadOnlyList<string> genres, string value)
{
for (var i = 0; i < genres.Count; i++)
{
if (string.Equals(genres[i], value, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,119 @@
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Answers "which actors are in the scene at time <c>t</c>" from a truth file.
/// </summary>
/// <remarks>
/// This is the unit that decides presence, so the scene-scoped semantics live
/// here rather than being spread through the controller.
///
/// <para>
/// <b>A window is a claim about scene membership, not a recognition event.</b>
/// 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 — so this code reads windows
/// exactly as given and never merges, splits, trims, or reorders them.
/// </para>
/// <para>
/// Bounds are inclusive at both ends, matching the format's definition. That
/// makes adjacent windows such as <c>[0,10]</c> and <c>[10,20]</c> both contain
/// <c>t = 10</c>; reporting the actor present once is correct, and is not a
/// reason to merge the windows.
/// </para>
/// </remarks>
// TRACES: JR-004, JR-005, JR-006 | SR-002
public static class PresenceLookup
{
/// <summary>
/// Determines whether an actor is present in the scene at <paramref name="t"/>.
/// </summary>
/// <param name="actor">The actor entry from a truth file.</param>
/// <param name="t">The timestamp, in seconds.</param>
/// <returns><c>true</c> when any window contains <paramref name="t"/>.</returns>
public static bool IsPresentAt(TruthActor actor, double t)
{
if (actor is null)
{
return false;
}
// A full scan, deliberately: windows may be numerous, but correctness
// must not depend on the producer having honoured the sortedness
// guarantee. An early exit on `start > t` would be faster and would
// silently under-report the moment one file arrived out of order —
// trading a correctness risk for a saving that does not matter at this
// scale (see JR-006).
foreach (var window in actor.Scenes)
{
if (window is not null && window.Start <= t && t <= window.End)
{
return true;
}
}
return false;
}
/// <summary>
/// Lists the actors present in the scene at <paramref name="t"/>, in the
/// order the truth file lists them.
/// </summary>
/// <param name="truth">The truth file.</param>
/// <param name="t">The timestamp, in seconds.</param>
/// <returns>The actors whose windows contain <paramref name="t"/>.</returns>
public static IEnumerable<TruthActor> ActorsPresentAt(TruthFile truth, double t)
{
if (truth is null)
{
yield break;
}
foreach (var actor in truth.Actors)
{
if (IsPresentAt(actor, t))
{
yield return actor;
}
}
}
/// <summary>
/// Determines whether an actor's windows are sorted by start time, as the
/// truth-file format requires of producers.
/// </summary>
/// <remarks>
/// Presence lookup does not depend on this — it is a diagnostic. A file that
/// fails it is still read correctly, but it signals a producer bug worth
/// surfacing rather than absorbing silently.
/// </remarks>
/// <param name="actor">The actor entry from a truth file.</param>
/// <returns><c>true</c> when every window starts at or after its predecessor.</returns>
public static bool WindowsAreSorted(TruthActor actor)
{
if (actor is null)
{
return true;
}
double previousStart = double.NegativeInfinity;
foreach (var window in actor.Scenes)
{
if (window is null)
{
continue;
}
if (window.Start < previousStart)
{
return false;
}
previousStart = window.Start;
}
return true;
}
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>Per-server reachability, for the configuration page.</summary>
public class ServerStatus
{
/// <summary>Gets or sets the server's base URL.</summary>
public string Url { get; set; } = string.Empty;
/// <summary>Gets or sets the display name.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the server is enabled.</summary>
public bool Enabled { get; set; }
/// <summary>Gets or sets a value indicating whether the last attempt succeeded.</summary>
public bool Reachable { get; set; }
/// <summary>Gets or sets the last error seen, if any.</summary>
public string? LastError { get; set; }
/// <summary>Gets or sets when this server will next be tried.</summary>
public DateTimeOffset? SkippedUntil { get; set; }
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>Identity and cut parameters for a fetch.</summary>
public class TitleQuery
{
/// <summary>Gets or sets the movie's TMDB id.</summary>
public string? TmdbId { get; set; }
/// <summary>Gets or sets the movie's IMDB id.</summary>
public string? ImdbId { get; set; }
/// <summary>Gets or sets the series TMDB id, for an episode.</summary>
public string? SeriesTmdbId { get; set; }
/// <summary>Gets or sets the season number, for an episode.</summary>
public int? Season { get; set; }
/// <summary>Gets or sets the episode number, for an episode.</summary>
public int? Episode { get; set; }
/// <summary>Gets or sets the local file's measured runtime, in seconds.</summary>
public double? RuntimeSec { get; set; }
/// <summary>Renders the query parameters the server expects.</summary>
/// <remarks>
/// There is deliberately no <c>video_hash</c> parameter. The file-hash tier
/// is withdrawn on legal grounds (see <see cref="MatchTier"/>), and omitting
/// the field here is what makes that structural: there is nothing to send,
/// so no future caller can start sending one by setting a property.
/// </remarks>
/// <returns>An escaped query string, without the leading '?'.</returns>
public string ToQueryString()
{
var parts = new List<string>();
void Add(string key, string? value)
{
if (!string.IsNullOrEmpty(value))
{
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
}
Add("tmdb_id", TmdbId);
Add("imdb_id", ImdbId);
Add("series_tmdb_id", SeriesTmdbId);
Add("season", Season?.ToString(CultureInfo.InvariantCulture));
Add("episode", Episode?.ToString(CultureInfo.InvariantCulture));
Add("runtime_sec", RuntimeSec?.ToString("0.###", CultureInfo.InvariantCulture));
return string.Join('&', parts);
}
}
@@ -16,6 +16,13 @@ namespace Jellyfin.Plugin.JRay.Services;
/// item's source file, and caches the parsed result for a configurable
/// duration.
/// </summary>
/// <remarks>
/// Managed truth — pushed by a worker, or fetched from a manifest server —
/// takes precedence over a sidecar file. Storing fetched manifests through the
/// managed store is what keeps this a two-way rule rather than a three-way one,
/// so the read path never learns that the exchange exists.
/// </remarks>
// TRACES: JR-008, JR-010, JR-011 | PR-001
public sealed class TruthDataService : ITruthDataService
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web);
@@ -76,16 +83,68 @@ public sealed class TruthDataService : ITruthDataService
using var stream = File.OpenRead(truthPath);
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false);
if (!TruthSchema.IsSupported(truth))
{
// Named, not silent. A stale v1 sidecar makes an item look
// un-extracted, and re-extracting a library that has already
// been processed is the most expensive mistake this plugin can
// cause a user to make. The log line is what distinguishes the
// two states.
_logger.LogWarning(
"JRay: ignoring truth file {TruthPath} — schema_version {Found}, expected {Expected}. Re-extract this item.",
truthPath,
truth?.SchemaVersion ?? 0,
TruthSchema.SupportedVersion);
_cache[itemId] = new CacheEntry(null, DateTime.UtcNow);
return null;
}
_cache[itemId] = new CacheEntry(truth, DateTime.UtcNow);
return truth;
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath);
// A v1 file also lands here rather than above: `scenes` was a float
// pair in v1 and is an object in v2, so it fails to deserialise
// before the version can be inspected. Both routes must therefore
// name the file, which is why the message below says the same thing.
_logger.LogWarning(
ex,
"JRay: failed to read truth file {TruthPath}. If this is a v1 file, re-extract the item — v1 is no longer read.",
truthPath);
return null;
}
}
/// <inheritdoc />
public TruthProvenance? GetProvenance(Guid itemId)
{
// Managed truth wins, exactly as in GetTruthAsync -- resolving
// precedence twice by different rules is how the two would drift.
var managed = _managedTruthStore.LoadProvenance(itemId);
if (managed is not null)
{
return managed;
}
var item = _libraryManager.GetItemById(itemId);
if (item is null || string.IsNullOrEmpty(item.Path))
{
return null;
}
var sidecarPath = GetSidecarPath(item.Path);
if (!File.Exists(sidecarPath))
{
return null;
}
// A sidecar records nothing about itself, so its provenance is derived:
// it is local, and its timestamp is the file's own.
return TruthProvenance.Local(TruthSource.Sidecar, File.GetLastWriteTimeUtc(sidecarPath));
}
/// <inheritdoc />
public void Invalidate(Guid itemId)
{
@@ -0,0 +1,69 @@
using System.Globalization;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// The one place that decides whether a truth file speaks a version this plugin
/// understands.
/// </summary>
/// <remarks>
/// <b>Flag day, not dual-accept.</b> Only <see cref="SupportedVersion"/> is
/// accepted; every other value is refused on every path — sidecar read, managed
/// <c>PUT</c>, managed store load, and converted manifest. There is no
/// transitional v1 read path.
/// <para>
/// 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 dragged
/// through every subsequent change to the reader.
/// </para>
/// <para>
/// <b>The consequence is stated rather than discovered:</b> v1 sidecar files
/// already on disk stop being read at the bump and stay dark until the library
/// is re-extracted. Callers log the rejection naming the file and the version
/// found, because an item that looks un-extracted when it is merely stale is the
/// failure mode that wastes a user's compute.
/// </para>
/// <para>
/// This exists as a shared unit because the check used to live only in the
/// <c>PUT</c> controller while sidecar reads did not check at all — so the
/// format the plugin claimed to require and the format it would actually parse
/// were different things.
/// </para>
/// </remarks>
// TRACES: JR-003 | SR-003
public static class TruthSchema
{
/// <summary>
/// The only <c>schema_version</c> this plugin reads or writes.
/// </summary>
/// <remarks>
/// System-level (SR-003): the same number means the same format in all three
/// repos, and is incremented once per breaking change across all of them.
/// </remarks>
public const int SupportedVersion = 2;
/// <summary>
/// Determines whether a truth file speaks the supported version.
/// </summary>
/// <param name="truth">The parsed truth file, which may be <c>null</c>.</param>
/// <returns><c>true</c> only when the version matches exactly.</returns>
public static bool IsSupported(TruthFile? truth)
=> truth is not null && truth.SchemaVersion == SupportedVersion;
/// <summary>
/// Builds the message describing why a truth file was refused.
/// </summary>
/// <remarks>
/// Names both the version found and the version expected. A rejection that
/// says only "unsupported" leaves the reader unable to tell a stale file
/// from a corrupt one.
/// </remarks>
/// <param name="found">The version encountered.</param>
/// <returns>A message naming both versions.</returns>
public static string DescribeRejection(int found)
=> string.Create(
CultureInfo.InvariantCulture,
$"Unsupported schema_version {found}; expected {SupportedVersion}. Re-extract the item — v1 truth data is no longer read.");
}
@@ -6,26 +6,40 @@ using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Injects (or removes) a script tag in the web client's <c>index.html</c>
/// that loads JRay's pause-overlay script. This follows the pattern used by
/// other Jellyfin plugins (e.g. Intro Skipper) since there is no official
/// plugin hook for player-overlay UI.
/// Removes the pause-overlay script tag that an earlier version of JRay
/// injected into the web client's <c>index.html</c> on disk.
/// </summary>
/// <remarks>
/// <b>This class cannot inject.</b> JRay reaches the web client only through
/// <see cref="FileTransformationRegistration"/>, which rewrites
/// <c>index.html</c> as it is served. Patching the file on disk was removed
/// rather than left switched off: the patch outlives an uninstall, a web-client
/// upgrade discards it silently, and it races any other plugin patching the same
/// file. An unreachable write path is also the one nobody runs, which is the one
/// a later refactor re-enables by accident.
///
/// Removal remains because users upgrading from a version that did patch the
/// file must not be left with a stale injection pointing at endpoints that have
/// since changed. It keys on JRay's own <c>&lt;!-- jray-overlay --&gt;</c>
/// marker, so it is unambiguous and touches nothing another plugin added.
/// </remarks>
// TRACES: JR-021, JR-022 | PR-004
public static class WebClientPatchService
{
private const string Marker = "<!-- jray-overlay -->";
private const string ScriptTag = "<script defer src=\"/Plugins/JRay/ClientScript\"></script>";
private const string Injected = ScriptTag + Marker + "\n</body>";
/// <summary>
/// Ensures the web client's index.html either has or does not have the
/// JRay overlay script injected, matching <paramref name="enableOverlay"/>.
/// Removes a legacy on-disk overlay injection, if one is present. Safe to
/// call on every startup: it is a no-op once the marker is gone.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="enableOverlay">Whether the overlay script should be present.</param>
/// <param name="logger">The logger.</param>
public static void Apply(IApplicationPaths applicationPaths, bool enableOverlay, ILogger logger)
public static void RemoveLegacyPatch(IApplicationPaths applicationPaths, ILogger logger)
{
ArgumentNullException.ThrowIfNull(applicationPaths);
ArgumentNullException.ThrowIfNull(logger);
var indexPath = Path.Combine(applicationPaths.WebPath, "index.html");
try
@@ -37,36 +51,38 @@ public static class WebClientPatchService
}
var html = File.ReadAllText(indexPath);
var hasMarker = html.Contains(Marker, StringComparison.Ordinal);
if (!html.Contains(Marker, StringComparison.Ordinal))
{
return;
}
if (enableOverlay && !hasMarker)
{
var patched = ReplaceLast(html, "</body>", Injected);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JRay: injected pause-overlay script into {Path}", indexPath);
}
else if (!enableOverlay && hasMarker)
{
var patched = html.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
File.WriteAllText(indexPath, patched);
logger.LogInformation("JRay: removed pause-overlay script from {Path}", indexPath);
}
var cleaned = RemoveInjection(html);
File.WriteAllText(indexPath, cleaned);
logger.LogInformation(
"JRay: removed a pause-overlay script left in {Path} by an earlier version. "
+ "JRay no longer modifies this file; the overlay is served through File Transformation.",
indexPath);
}
catch (Exception ex) when (ex is IOException or UnauthorizedAccessException)
{
logger.LogWarning(ex, "JRay: failed to patch web client index.html at {Path}", indexPath);
logger.LogWarning(ex, "JRay: failed to remove the legacy overlay patch from {Path}", indexPath);
}
}
private static string ReplaceLast(string source, string find, string replace)
/// <summary>
/// Strips the marked script tag from the document.
/// </summary>
/// <param name="html">The document contents.</param>
/// <returns>The contents with JRay's injection removed.</returns>
internal static string RemoveInjection(string html)
{
var index = source.LastIndexOf(find, StringComparison.Ordinal);
if (index < 0)
{
return source;
}
ArgumentNullException.ThrowIfNull(html);
return source[..index] + replace + source[(index + find.Length)..];
// The trailing newline is stripped with the tag when present, so removing
// a patch restores the document byte-for-byte rather than leaving a blank
// line that accumulates across upgrades.
return html
.Replace(ScriptTag + Marker + "\n", string.Empty, StringComparison.Ordinal)
.Replace(ScriptTag + Marker, string.Empty, StringComparison.Ordinal);
}
}
+266 -16
View File
@@ -1,15 +1,80 @@
/*
* 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';
var POLL_INTERVAL_MS = 1000;
var OVERVIEW_MAX_LENGTH = 160;
var overlayEl = null;
var detailEl = null;
var personCache = {};
function getItemIdFromHash() {
var match = window.location.hash.match(/[?&]id=([0-9a-fA-F-]+)/);
return match ? match[1] : null;
function truncate(text, maxLength) {
if (text.length <= maxLength) {
return text;
}
return text.slice(0, maxLength).trim() + '…';
}
function getPerson(jellyfinId) {
if (personCache[jellyfinId]) {
return personCache[jellyfinId];
}
var promise = window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), jellyfinId)
.catch(function () {
return null;
});
personCache[jellyfinId] = promise;
return promise;
}
function getNowPlaying() {
if (!window.ApiClient) {
return Promise.reject(new Error('no ApiClient'));
}
var url = window.ApiClient.getUrl('Sessions', { DeviceId: window.ApiClient.deviceId() });
return window.ApiClient.ajax({ url: url, type: 'GET', dataType: 'json' }).then(function (sessions) {
var session = sessions && sessions[0];
if (!session || !session.NowPlayingItem || !session.PlayState) {
return null;
}
return {
itemId: session.NowPlayingItem.Id,
positionSeconds: (session.PlayState.PositionTicks || 0) / 10000000
};
});
}
function removeDetail() {
if (detailEl && detailEl.parentNode) {
detailEl.parentNode.removeChild(detailEl);
}
detailEl = null;
document.removeEventListener('keydown', onDetailKeydown, true);
}
function removeOverlay() {
removeDetail();
if (overlayEl && overlayEl.parentNode) {
overlayEl.parentNode.removeChild(overlayEl);
}
@@ -17,6 +82,119 @@
overlayEl = null;
}
function onDetailKeydown(event) {
// Back button on TV remotes / keyboards maps to Escape / Backspace.
if (event.key === 'Escape' || event.key === 'Backspace' || event.keyCode === 27 || event.keyCode === 8) {
event.stopPropagation();
event.preventDefault();
removeDetail();
}
}
// A larger, closeable "pop-up" card shown over the video when an actor is
// clicked. It stays inside the player so playback position is never lost.
function showDetail(container, actor, person) {
removeDetail();
detailEl = document.createElement('div');
detailEl.className = 'jrayActorDetail';
detailEl.style.position = 'absolute';
detailEl.style.top = '0';
detailEl.style.left = '0';
detailEl.style.right = '0';
detailEl.style.bottom = '0';
detailEl.style.zIndex = '10000';
detailEl.style.display = 'flex';
detailEl.style.alignItems = 'center';
detailEl.style.justifyContent = 'center';
detailEl.style.background = 'rgba(0, 0, 0, 0.6)';
detailEl.style.pointerEvents = 'auto';
// Click on the dimmed backdrop closes the pop-up.
detailEl.addEventListener('click', function (event) {
if (event.target === detailEl) {
removeDetail();
}
});
var panel = document.createElement('div');
panel.style.position = 'relative';
panel.style.display = 'flex';
panel.style.gap = '24px';
panel.style.maxWidth = '720px';
panel.style.width = '80%';
panel.style.maxHeight = '80%';
panel.style.overflowY = 'auto';
panel.style.background = 'rgba(20, 20, 20, 0.96)';
panel.style.color = '#fff';
panel.style.padding = '24px';
panel.style.borderRadius = '10px';
panel.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.6)';
if (person && person.ImageTags && person.ImageTags.Primary) {
var img = document.createElement('img');
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
type: 'Primary',
maxHeight: 400,
tag: person.ImageTags.Primary
});
img.style.height = '300px';
img.style.width = 'auto';
img.style.borderRadius = '8px';
img.style.objectFit = 'cover';
img.style.flexShrink = '0';
panel.appendChild(img);
}
var text = document.createElement('div');
text.style.flex = '1';
var name = document.createElement('div');
name.style.fontWeight = 'bold';
name.style.fontSize = '24px';
name.style.marginBottom = '12px';
name.textContent = actor.name;
text.appendChild(name);
if (person && person.Overview) {
var overview = document.createElement('div');
overview.style.fontSize = '15px';
overview.style.lineHeight = '1.5';
overview.style.opacity = '0.9';
overview.textContent = person.Overview;
text.appendChild(overview);
}
panel.appendChild(text);
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '✕';
closeBtn.style.position = 'absolute';
closeBtn.style.top = '8px';
closeBtn.style.right = '8px';
closeBtn.style.width = '32px';
closeBtn.style.height = '32px';
closeBtn.style.border = 'none';
closeBtn.style.borderRadius = '50%';
closeBtn.style.background = 'rgba(255, 255, 255, 0.15)';
closeBtn.style.color = '#fff';
closeBtn.style.fontSize = '16px';
closeBtn.style.cursor = 'pointer';
closeBtn.addEventListener('click', function (event) {
event.stopPropagation();
removeDetail();
});
panel.appendChild(closeBtn);
detailEl.appendChild(panel);
container.appendChild(detailEl);
// Back button (Escape/Backspace) closes the pop-up first.
document.addEventListener('keydown', onDetailKeydown, true);
}
function showOverlay(video, actors) {
removeOverlay();
@@ -40,15 +218,87 @@
overlayEl.style.gap = '12px';
overlayEl.style.pointerEvents = 'none';
// "In this scene", not "on screen now". Presence is scene-scoped, so
// this list includes people the camera is not currently pointing at —
// without the heading a viewer reads a paused frame and concludes the
// overlay is wrong whenever someone is off-camera mid-conversation.
var heading = document.createElement('div');
heading.className = 'jrayOverlayHeading';
heading.textContent = 'In this scene';
heading.style.width = '100%';
heading.style.color = '#fff';
heading.style.opacity = '0.75';
heading.style.fontSize = '13px';
heading.style.textTransform = 'uppercase';
heading.style.letterSpacing = '0.08em';
heading.style.textShadow = '0 1px 3px rgba(0,0,0,0.9)';
overlayEl.appendChild(heading);
actors.forEach(function (actor) {
var card = document.createElement('div');
card.style.background = 'rgba(0, 0, 0, 0.7)';
card.className = 'jrayActorCard';
card.style.background = 'rgba(0, 0, 0, 0.75)';
card.style.color = '#fff';
card.style.padding = '6px 12px';
card.style.borderRadius = '4px';
card.style.fontSize = '14px';
card.textContent = actor.name;
card.style.padding = '8px 12px';
card.style.borderRadius = '6px';
card.style.display = 'flex';
card.style.alignItems = 'center';
card.style.gap = '10px';
card.style.maxWidth = '360px';
var textBlock = document.createElement('div');
var name = document.createElement('div');
name.style.fontWeight = 'bold';
name.style.fontSize = '16px';
name.textContent = actor.name;
textBlock.appendChild(name);
card.appendChild(textBlock);
overlayEl.appendChild(card);
if (!actor.jellyfin_id || !window.ApiClient) {
return;
}
card.style.pointerEvents = 'auto';
card.style.cursor = 'pointer';
getPerson(actor.jellyfin_id).then(function (person) {
if (!person || !overlayEl || !overlayEl.contains(card)) {
return;
}
// Clicking the card opens the in-player detail pop-up rather than
// navigating away — this keeps the current playback position.
card.addEventListener('click', function (event) {
event.stopPropagation();
showDetail(container, actor, person);
});
if (person.ImageTags && person.ImageTags.Primary) {
var img = document.createElement('img');
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
type: 'Primary',
maxHeight: 120,
tag: person.ImageTags.Primary
});
img.style.height = '120px';
img.style.width = 'auto';
img.style.borderRadius = '4px';
img.style.objectFit = 'cover';
card.insertBefore(img, textBlock);
}
if (person.Overview) {
var overview = document.createElement('div');
overview.style.fontSize = '12px';
overview.style.opacity = '0.85';
overview.style.marginTop = '4px';
overview.textContent = truncate(person.Overview, OVERVIEW_MAX_LENGTH);
textBlock.appendChild(overview);
}
});
});
container.appendChild(overlayEl);
@@ -72,14 +322,14 @@
});
}
function onPause(event) {
var video = event.target;
var itemId = getItemIdFromHash();
if (!itemId) {
return;
}
fetchContext(itemId, video.currentTime);
function onPause() {
getNowPlaying().then(function (info) {
if (info) {
fetchContext(info.itemId, info.positionSeconds);
}
}, function () {
// ApiClient unavailable or request error - fail silently, never break playback.
});
}
function onPlay() {
+344 -359
View File
@@ -1,6 +1,19 @@
# JRay
JRay reads "truth" files produced offline by the scene-actor-extraction pipeline (face detection + recognition) and exposes an API to query which actors are visible on screen at a given timestamp in a movie, for building an actor-overlay (Jellyfin "X-Ray") style feature.
A Jellyfin plugin that brings an actor-overlay (think Amazon "X-Ray") feature to your media: pause a movie and JRay shows you which actors are in the scene you paused in.
JRay reads "truth" files produced offline by the
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
pipeline (face detection + recognition) and exposes an API to query which actors
are present in the scene at a given timestamp. A small overlay, injected into the Jellyfin web
client, displays the result when you pause playback.
## Status
**Alpha** — JRay works end to end (sidecar truth files, remote truth push, and the
pause overlay) but is early software and the truth-file schema may still change.
The overlay relies on patching the web client's `index.html`, which is inherently
a little fragile across Jellyfin versions (see [Important Notes](#important-notes)).
## Quick Install
@@ -10,422 +23,394 @@ Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories):
https://gitea.tourolle.paris/dtourolle/jRay/raw/branch/master/manifest.json
```
Then install "JRay" from the plugin catalog.
Then install "JRay" from the plugin catalog and restart Jellyfin.
---
### Required for the overlay: File Transformation
# So you want to make a Jellyfin plugin
JRay's pause overlay needs a script tag in the web client's `index.html`.
Install [File Transformation](https://github.com/IAmParadox27/jellyfin-plugin-file-transformation)
(repository `https://www.iamparadox.dev/jellyfin/plugins/manifest.json`) **before**
installing JRay. It rewrites the page as it is served, so the file on disk is
never touched — that survives server upgrades and coexists with other plugins
patching the same file.
Awesome! This guide is for you. Jellyfin plugins are written using the dotnet standard framework. What that means is you can write them in any language that implements the CLI or the DLI and can compile to net8.0. The examples on this page are in C# because that is what most of Jellyfin is written in, but F#, Visual Basic, and IronPython should all be compatible once compiled.
**There is no fallback.** JRay never edits `index.html` on disk: a patch there
outlives an uninstall, is silently discarded by a web-client upgrade, and races
any other plugin touching the file. Without File Transformation the pause
overlay is simply disabled — every other JRay feature works normally, and the
plugin's configuration page tells you what is missing and how to install it.
## 0. Things you need to get started
Upgrading from an older JRay that did patch `index.html`? It removes its own
patch on startup, so there is nothing to clean up by hand. See
[SPEC.md](SPEC.md) JR-021/JR-022.
- [Dotnet SDK 9.0](https://dotnet.microsoft.com/en-us/download/dotnet)
## Features
- An editor of your choice. Some free choices are:
- **Pause overlay** — pause a movie or episode in the web client and see the
actors in the current scene, without leaving the player.
- **Sidecar truth files** — drop a `Movie.jray.json` next to `Movie.mkv` and JRay
picks it up automatically (suffix configurable).
- **Remote truth push** — for servers that can't run the extraction pipeline
locally, a remote worker can `PUT` truth data over HTTP. Managed (pushed) data
takes precedence over sidecar files.
- **Work discovery API** — a remote worker can poll for a random batch of library
items that still need processing, so the backlog spreads naturally across
workers without server-side task tracking.
- **Prioritise / ignore rules** — steer that backlog by genre, series, or
individual item: ignore what you never want extracted (e.g. anime), or push a
series to the front of the queue.
- **Coverage overview** — see at a glance how much of your library has actor
data, broken down by genre and by media type (film vs TV).
- **Extensible "context at time t" envelope** — the per-timestamp response is
designed to grow (locations, trivia, …) without breaking existing clients.
- **In-memory caching** — loaded truth files are cached with a configurable TTL.
- **Toggleable overlay** — disabling the overlay also cleanly removes the injected
script from the web client.
[Visual Studio Code](https://code.visualstudio.com)
[Visual Studio Community Edition](https://visualstudio.microsoft.com/downloads)
[Mono Develop](https://www.monodevelop.com)
## 0.5. Quickstarts
We have a number of quickstart options available to speed you along the way.
- [Download the Example Plugin Project](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/Jellyfin.Plugin.Template) from this repository, open it in your IDE and go to [step 3](https://github.com/jellyfin/jellyfin-plugin-template#3-customize-plugin-information)
- Install our dotnet template by [downloading the dotnet-template/content folder from this repo](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/dotnet-template/content) or off of Nuget (Coming soon)
```
dotnet new -i /path/to/templatefolder
```
- Run this command then skip to step 4
```
dotnet new Jellyfin-plugin -name MyPlugin
```
If you'd rather start from scratch keep going on to step one. This assumes no specific editor or IDE and requires only the command line with dotnet in the path.
## 1. Initialize Your Project
Make a new dotnet standard project with the following command, it will make a directory for itself.
## How It Works
```
dotnet new classlib -f net9.0 -n MyJellyfinPlugin
scene-actor-extraction Jellyfin server web client
(offline pipeline) (browser)
┌────────────────────┐ ┌──────────────────┐ ┌────────────┐
│ face detection + │ truth │ JRay plugin │ jray?t= │ pause │
│ recognition │ ───────► │ - sidecar reader │ ◄─────── │ overlay │
│ result_sink_node │ file │ - managed store │ actors │ script │
└────────────────────┘ │ - REST API │ ───────► └────────────┘
│ PUT /Truth (remote) │ - web patcher │
└────────────────────────►└──────────────────┘
```
Now add the Jellyfin shared libraries.
1. The extraction pipeline analyses a film offline and emits a **truth file**
listing each actor and the time windows they are present in the film.
2. JRay loads that truth file either from a **sidecar** next to the media
(`Movie.jray.json`) or from a **managed store** populated via the push API.
3. On startup JRay injects a small `<script>` into the web client's `index.html`.
When you pause, the script calls JRay for the current item and timestamp and
renders the scene's cast as an overlay.
```
dotnet add package Jellyfin.Model
dotnet add package Jellyfin.Controller
```
## Truth File Format
You have an autogenerated Class1.cs file. You won't be needing this, so go ahead and delete it.
For a media file `Movie.mkv`, JRay looks for a sibling `Movie.jray.json` (suffix
configurable). Schema (`schema_version: 1`, minimal verbosity):
Navigate to the csproj that was generated, and ensure that you modify the package references to exclude assets, so that unnecessary files aren't copied over.
Skipping this step will prevent your plugin from registering correctly.
```
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.3">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.3">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
```
Note: Ensure the package reference version matches the install version of jellyfin server, otherwise the plugin will show as NotSupported.
## 2. Set Up the Basics
There are a few mandatory classes you'll need for a plugin so we need to make them.
### PluginConfiguration
Create a folder named "Configuration", and a PluginConfiguration.cs file inside.
You can call it whatever you'd like really. This class is used to hold settings your plugin might need. We can leave it empty for now. This class should inherit from `MediaBrowser.Model.Plugins.BasePluginConfiguration`
It should look something like the following:
```c#
using MediaBrowser.Model.Plugins;
namespace MyJellyfinPlugin.Configuration;
class PluginConfiguration : BasePluginConfiguration
```json
{
"schema_version": 1,
"movie": "/path/to/Movie.mkv",
"sample_fps": 1,
"anneal_sec": 2,
"actors": [
{
"name": "Tom Hanks",
"imdb_id": "nm0000158",
"tmdb_id": "31",
"jellyfin_id": "abc123-guid",
"scenes": [[12.0, 45.0], [102.5, 150.0]]
}
]
}
```
### Plugin
An actor is present at timestamp `t` (seconds) if any of their `scenes` windows
satisfies `start <= t <= end`. **A window is a claim about scene membership, not
a recognition event** — an actor who has turned away or is off-camera during a
reverse shot is still present, and two windows mean a genuine departure and
return rather than a break in detection. JRay prefers `jellyfin_id` (a
Jellyfin Person GUID) when present, otherwise resolves `imdb_id`/`tmdb_id`
against the item's People `ProviderIds`.
This is the main class for your plugin and will reside in the root of your project. It will define your name, version and Id. It should inherit from `MediaBrowser.Common.Plugins.BasePlugin<PluginConfiguration>`
See [SPEC.md](SPEC.md) for the full schema and field-by-field reference.
It should look something like the following:
```c#
using MediaBrowser.Common.Plugins;
using MyJellyfinPlugin.Configuration;
namespace MyJellyfinPlugin;
class Plugin : BasePlugin<PluginConfiguration>
{
}
## API
All routes are served under `/Plugins/JRay`. Authentication uses Jellyfin's
standard scheme — pass a token (a user access token or an API key) as either
the `X-Emby-Token: <token>` header or `Authorization: MediaBrowser Token="<token>"`.
| Method & Route | Auth | Description |
| --- | --- | --- |
| `GET /Items/{itemId}/jray?t={seconds}` | user | "Context at time t" envelope (the scene's cast), or `404`. |
| `GET /Items/{itemId}/Timeline` | user | Full truth file for an item, or `404` if none. |
| `PUT /Items/{itemId}/Truth` | admin | Push managed truth data (schema v1). `204` on success, `400` on bad schema. |
| `DELETE /Items/{itemId}/Truth` | admin | Remove managed truth data (idempotent, `204`). Falls back to sidecar. |
| `GET /Tasks/Pending?limit=10` | admin | Random sample of items still needing truth data (default 10, max 100); honours prioritise/ignore rules. |
| `GET /Policy/Rules` | admin | List prioritise/ignore rules. |
| `PUT /Policy/Rules` | admin | Add or replace a rule. `204`, or `400` if `value` is empty. |
| `DELETE /Policy/Rules?scope=&value=` | admin | Remove a rule (idempotent, `204`). |
| `GET /Coverage` | admin | Library coverage totals, plus breakdowns by media type and genre. |
| `GET /Coverage/Genres` \| `/Series` \| `/Items?search=` | admin | Option lists for the config page's rule editor. |
| `GET /ClientScript` | anon | The pause-overlay script injected into the web client. |
- **user** — any authenticated Jellyfin user token.
- **admin** — a token belonging to a user with the **Administrator** role
(create an API key under Dashboard → API Keys).
- **anon** — no authentication required.
## Client-Side Integration
JRay is designed so that *any* Jellyfin client (not just the bundled web overlay)
can build an actor-overlay feature. The integration is two calls: figure out
**what is playing and where**, then ask JRay **who is in the scene**.
### 1. Query the scene's cast: `GET /Items/{itemId}/jray?t={seconds}`
Given a Jellyfin item id and a playback position in **seconds**, returns the
the actors in that scene. This is the only call most clients need.
**Request**
```
GET /Plugins/JRay/Items/abc123-guid/jray?t=87.5
X-Emby-Token: <user-or-api-token>
```
Note: If you called your PluginConfiguration class something different, you need to put that between the <>
**Response — `200 OK`**
### Implement Required Properties
The Plugin class needs a few properties implemented before it can work correctly.
It needs an override on ID, an override on Name, and a constructor that follows a specific model. To get started you can use the following section.
```c#
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer){}
public override string Name => throw new System.NotImplementedException();
public override Guid Id => Guid.Parse("");
```json
{
"actors": [
{
"name": "Tom Hanks",
"imdb_id": "nm0000158",
"tmdb_id": "31",
"jellyfin_id": "abc123-guid"
}
]
}
```
## 3. Customize Plugin Information
- `actors` may be an **empty array** when no one is in the scene at `t` — that's a
`200`, not a `404`.
- `404 Not Found` means the item has **no truth data at all** (no managed upload
and no sidecar file). Treat this as "JRay isn't available for this item" and
silently skip — don't surface an error to the viewer.
- The id fields are `""` when unresolved. Prefer `jellyfin_id` (a Jellyfin Person
GUID) to deep-link into the library or fetch a headshot; fall back to
`imdb_id` / `tmdb_id` for external links.
- The top-level object is an **extensible envelope**: future releases may add
sibling keys (e.g. `locations`, `trivia`) alongside `actors`. **Ignore unknown
keys** so your client keeps working across versions.
You need to populate some of your plugin's information. Go ahead a put in a string of the Name you've overridden name, and generate a GUID
### 2. (Optional) Pre-fetch the whole timeline: `GET /Items/{itemId}/Timeline`
- **Windows Users**: you can use the Powershell command `New-Guid`, `[guid]::NewGuid()` or the Visual Studio GUID generator
Returns the complete truth file (the [schema above](#truth-file-format)) — every
actor with all their scene windows. Use this if you'd rather fetch once and
compute "who is in the scene" client-side (e.g. to drive a scrubber-bar heatmap)
instead of polling `jray?t=` on each pause. `404` if no truth data exists.
- **Linux and OS X Users**: you can use the Powershell Core command `New-Guid` or this command from your shell of choice:
### Reference implementation (web client)
```bash
od -x /dev/urandom | head -n1 | awk '{OFS="-"; srand($6); sub(/./,"4",$5); sub(/./,substr("89ab",1+rand()*4,1),$6); print $2$3,$4,$5,$6,$7$8$9}'
```
The bundled overlay ([`Web/jray-overlay.js`](Jellyfin.Plugin.JRay/Web/jray-overlay.js))
shows the full pattern using Jellyfin's `ApiClient`, and is a good template for a
custom client:
or
```js
// 1. Find what's playing and the current position (in seconds).
var sessions = await ApiClient.ajax({
url: ApiClient.getUrl('Sessions', { DeviceId: ApiClient.deviceId() }),
type: 'GET', dataType: 'json'
});
var s = sessions[0];
var itemId = s.NowPlayingItem.Id;
var t = (s.PlayState.PositionTicks || 0) / 10000000; // ticks → seconds
```bash
uuidgen
```
// 2. Ask JRay who is in the scene. ApiClient adds the auth token for you.
var ctx = await ApiClient.ajax({
url: ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t }),
type: 'GET', dataType: 'json'
});
- Place that guid inside the `Guid.Parse("")` quotes to define your plugin's ID.
## 4. Adding Functionality
Congratulations, you now have everything you need for a perfectly functional functionless Jellyfin plugin! You can try it out right now if you'd like by compiling it, then placing the dll you generate in a subfolder (named after your plugin for example) within the plugins folder under your Jellyfin directory (Normally C:\Users\{YourUserName}\AppData\Local\jellyfin\plugins). If you want to try and hook it up to a debugger make sure you copy the generated PDB file alongside it.
Most people aren't satisfied with just having an entry in a menu for their plugin, most people want to have some functionality, so lets look at how to add it.
### 4a. Implement Interfaces
If the functionality you are trying to add is functionality related to something that Jellyfin has an interface for you're in luck. Jellyfin uses some automatic discovery and injection to allow any interfaces you implement in your plugin to be available in Jellyfin.
Here's some interfaces you could implement for common use cases:
- **IAuthenticationProvider** - Allows you to add an authentication provider that can authenticate a user based on a name and a password, but that doesn't expect to deal with local users.
- **IBaseItemComparer** - Allows you to add sorting rules for dealing with media that will show up in sort menus
- **IIntroProvider** - Allows you to play a piece of media before another piece of media (i.e. a trailer before a movie, or a network bumper before an episode of a show)
- **IItemResolver** - Allows you to define custom media types
- **ILibraryPostScanTask** - Allows you to define a task that fires after scanning a library
- **IMetadataSaver** - Allows you to define a metadata standard that Jellyfin can use to write metadata
- **IResolverIgnoreRule** - Allows you to define subpaths that are ignored by media resolvers for use with another function (i.e. you wanted to have a theme song for each tv series stored in a subfolder that could be accessed by your plugin for playback in a menu).
- **IScheduledTask** - Allows you to create a scheduled task that will appear in the scheduled task lists on the dashboard.
There are loads of other interfaces that can be used, but you'll need to poke around the API to get some info. If you're an expert on a particular interface, you should help [contribute some documentation](https://docs.jellyfin.org/general/contributing/index.html)!
### 4b. Use plugin aimed interfaces to add custom functionality
If your plugin doesn't fit perfectly neatly into a predefined interface, never fear, there are a set of interfaces and classes that allow your plugin to extend Jellyfin any which way you please. Here's a quick overview on how to use them
- **IPluginConfigurationPage** - Allows you to have a plugin config page on the dashboard. If you used one of the quickstart example projects, a premade page with some useful components to work with has been created for you! If not you can check out this guide here for how to whip one up.
**IPluginServiceRegistrator** - Will be located by Jellyfin at server startup and allows you to add services to the DI container to allow for injection in your plugin's classes later.
- **IHostedService** - Allows you to run code as a background task that will be started at program startup and will remain in memory. See [Microsoft's documentation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-8.0&tabs=visual-studio#ihostedservice-interface) for more information. You can make as many of these as you need; make Jellyfin aware of them with an `IPluginServiceRegistrator`. It is wildly useful for loading configs or persisting state. **Be aware that your main plugin class (IBasePlugin) cannot also be a IHostedService.**
- **ControllerBase** - Allows you to define custom REST-API endpoints. This is the default ASP.NET Web-API controller. You can use it exactly as you would in a normal Web-API project. Learn more about it [here](https://docs.microsoft.com/aspnet/core/web-api/?view=aspnetcore-5.0).
Likewise you might need to get data and services from the Jellyfin core, Jellyfin provides a number of interfaces you can add as parameters to your plugin constructor which are then made available in your project (you can see the 2 mandatory ones that are needed by the plugin system in the constructor as is).
- **IBlurayExaminer** - Allows you to examine blu-ray folders
- **IDtoService** - Allows you to create data transport objects, presumably to send to other plugins or to the core
- **ILibraryManager** - Allows you to directly access the media libraries without hopping through the API
- **ILocalizationManager** - Allows you tap into the main localization engine which governs translations, rating systems, units etc...
- **INetworkManager** - Allows you to get information about the server's networking status
- **IServerApplicationPaths** - Allows you to get the running server's paths
- **IServerConfigurationManager** - Allows you to write or read server configuration data into the application paths
- **ITaskManager** - Allows you to execute and manipulate scheduled tasks
- **IUserManager** - Allows you to retrieve user info and user library related info
- **IXmlSerializer** - Allows you to use the main xml serializer
- **IZipClient** - Allows you to use the core zip client for compressing and decompressing data
## 5. Create a Repository
- [See blog post](https://jellyfin.org/posts/plugin-updates/)
## 6. Set Up Debugging
Debugging can be set up by creating tasks which will be executed when running the plugin project. The specifics on setting up these tasks are not included as they may differ from IDE to IDE. The following list describes the general process:
- Compile the plugin in debug mode.
- Create the plugin directory if it doesn't exist.
- Copy the plugin into your server's plugin directory. The server will then execute it.
- Make sure to set the working directory of the program being debugged to the working directory of the Jellyfin Server.
- Start the server.
Some IDEs like Visual Studio Code may need the following compile flags to compile the plugin:
```shell
dotnet build Your-Plugin.sln /property:GenerateFullPaths=true /consoleloggerparameters:NoSummary
// 3. Render ctx.actors. A 404 (no truth data) rejects the promise — swallow it.
```
These flags generate the full paths for file names and **do not** generate a summary during the build process as this may lead to duplicate errors in the problem panel of your IDE.
Notes for client authors, learned from the reference overlay:
### 6.a Set Up Debugging on Visual Studio
- **Position is in seconds.** Jellyfin reports `PositionTicks` (100 ns units);
divide by `10_000_000` before passing as `t`.
- **Fail silently.** A `404` or any network error must never interrupt playback —
just render nothing.
- **Enrich via the core API.** JRay returns ids, not images/bios. Use
`jellyfin_id` with the standard Jellyfin item/image endpoints (e.g.
`ApiClient.getItem(...)` / `getImageUrl(...)`) to show headshots and overviews,
and deep-link to `#/details?id=<jellyfin_id>`.
- **Refresh on player events.** The overlay recomputes on `pause` and clears on
`play` / `playing` / `seeking`. Poll `jray?t=` again after a seek rather than
reusing a stale result.
Visual Studio allows developers to connect to other processes and debug them, setting breakpoints and inspecting the variables of the program. We can set this up following this steps:
On this section we will explain how to set up our solution to enable debugging before the server starts.
### Pushing truth data (remote extraction workers)
1. Right-click on the solution, And click on Add -> Existing Project...
2. Locate Jellyfin executable in your installation folder and click on 'Open'. It is called `Jellyfin.exe`. Now The solution will have a new "Project" called Jellyfin. This is the executable, not the source code of Jellyfin.
3. Right-click on this new project and click on 'Set up as Startup Project'
4. Right-click on this new project and click on 'Properties'
5. Make sure that the 'Attach' parameter is set to 'No'
For the full remote-worker workflow — authenticate, resolve the item id by
`Path`, and `PUT` the truth file — see
[SPEC.md](SPEC.md#client-pushing-results-from-a-remote-extraction-worker).
These endpoints require an **Administrator** token.
From now on, everytime you click on start from Visual Studio, it will start Jellyfin attached to the debugger!
## Configuration
The only thing left to do is to compile the project as it is specified a few lines above and you are done.
Configure JRay in Jellyfin Dashboard → Plugins → JRay:
### 6.b Automate the Setup on Visual Studio Code
- **Truth File Suffix** — filename suffix used to locate sidecar truth files next
to media (default `.jray.json`, e.g. `Movie.mkv``Movie.jray.json`).
- **Cache Duration (minutes)** — how long a loaded truth file is cached in memory
before being re-read from disk (default `60`).
- **Enable pause overlay** — whether JRay injects its overlay script into the web
client's `index.html`. Disabling it removes any previously injected script
(default `on`).
Visual Studio Code allows developers to automate the process of starting all necessary dependencies to start debugging the plugin. This guide assumes the reader is familiar with the [documentation on debugging in Visual Studio Code](https://code.visualstudio.com/docs/editor/debugging) and has read the documentation in this file. It is assumed that the Jellyfin Server has already been compiled once. However, should one desire to automatically compile the server before the start of the debugging session, this can be easily implemented, but is not further discussed here.
The config page also shows a **coverage overview** (how much of your library has
actor data, by genre and media type) and a **prioritise / ignore rules** editor.
Rules steer the work-discovery API (`/Tasks/Pending`) only — they don't affect
the overlay or read endpoints:
A full example, which aims to be portable may be found in this repo's `.vscode` folder.
- **Ignore** — matching items are never offered to extraction workers. Use it to
skip content you don't want processed (e.g. a whole genre like anime, a
specific series, or one movie). Ignored items are excluded from the coverage
"percent done" so they don't count against you.
- **Prioritise** — matching items jump to the front of the work queue.
This example expects you to clone `jellyfin`, `jellyfin-web` and `jellyfin-plugin-template` under the same parent directory, though you can customize this in `settings.json`
A rule targets a **genre**, a **series**, or a single **item**; the most specific
matching rule wins (item > series > genre). Setting a rule for a target that
already has one replaces it, so nothing can be both prioritised and ignored.
1. Create a `settings.json` file inside your `.vscode` folder, to specify common options specific to your local setup.
```jsonc
{
// jellyfinDir : The directory of the cloned jellyfin server project
// This needs to be built once before it can be used
"jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server",
// jellyfinWebDir : The directory of the cloned jellyfin-web project
// This needs to be built once before it can be used
"jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web",
// jellyfinDataDir : the root data directory for a running jellyfin instance
// This is where jellyfin stores its configs, plugins, metadata etc
// This is platform specific by default, but on Windows defaults to
// ${env:LOCALAPPDATA}/jellyfin
"jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin",
// The name of the plugin
"pluginName" : "Jellyfin.Plugin.Template",
}
```
## Building the Plugin
1. To automate the launch process, create a new `launch.json` file for C# projects inside the `.vscode` folder. The example below shows only the relevant parts of the file. Adjustments to your specific setup and operating system may be required.
### Prerequisites
```jsonc
{
// Paths and plugin names are configured in settings.json
"version": "0.2.0",
"configurations": [
{
"type": "coreclr",
"name": "Launch",
"request": "launch",
"preLaunchTask": "build-and-copy",
"program": "${config:jellyfinDir}/bin/Debug/net8.0/jellyfin.dll",
"args": [
//"--nowebclient"
"--webdir",
"${config:jellyfinWebDir}/dist/"
],
"cwd": "${config:jellyfinDir}",
}
]
}
- [.NET SDK 9.0](https://dotnet.microsoft.com/en-us/download/dotnet)
- Jellyfin 10.9.0 or later (built against `Jellyfin.Controller` 10.11.5)
```
### Build Steps
The `request` type is specified as `launch`, as this `launch.json` file will start the Jellyfin Server process. The `preLaunchTask` defines a task that will run before the Jellyfin Server starts. More on this later. It is important to set the `program` path to the Jellyin Server program and set the current working directory (`cwd`) to the working directory of the Jellyfin Server.
The `args` option allows to specify arguments to be passed to the server, e.g. whether Jellyfin should start with the web-client or without it.
```bash
dotnet publish Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj -c Release
```
2. Create a `tasks.json` file inside your `.vscode` folder and specify a `build-and-copy` task that will run in `sequence` order. This tasks depends on multiple other tasks and all of those other tasks can be defined as simple `shell` tasks that run commands like the `cp` command to copy a file. The sequence to run those tasks in is given below. Please note that it might be necessary to adjust the examples for your specific setup and operating system.
The compiled `Jellyfin.Plugin.JRay.dll` will be under
`Jellyfin.Plugin.JRay/bin/Release/net9.0/publish/`.
The full file is shown here - Specific sections will be discussed in depth
```jsonc
{
// Paths and plugin name are configured in settings.json
"version": "2.0.0",
"tasks": [
{
// A chain task - build the plugin, then copy it to your
// jellyfin server's plugin directory
"label": "build-and-copy",
"dependsOrder": "sequence",
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
},
{
// Build the plugin
"label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"publish",
"${workspaceFolder}/${config:pluginName}.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
// Ensure the plugin directory exists before trying to use it
"label": "make-plugin-dir",
"type": "shell",
"command": "mkdir",
"args": [
"-Force",
"-Path",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
{
// Copy the plugin dll to the jellyfin plugin install path
// This command copies every .dll from the build directory to the plugin dir
// Usually, you probablly only need ${config:pluginName}.dll
// But some plugins may bundle extra requirements
"label": "copy-dll",
"type": "shell",
"command": "cp",
"args": [
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
A reproducible build via the bundled Docker image is also available:
},
]
}
```bash
docker build -f Dockerfile.builder -t jray-builder .
```
```
1. The "build-and-copy" task which triggers all of the other tasks
```jsonc
{
// A chain task - build the plugin, then copy it to your
// jellyfin server's plugin directory
"label": "build-and-copy",
"dependsOrder": "sequence",
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
},
```
2. A build task. This task builds the plugin without generating summary, but with full paths for file names enabled.
## Manual Installation
```jsonc
{
// Build the plugin
"label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"publish",
"${workspaceFolder}/${config:pluginName}.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
```
1. Build the plugin (see above).
2. Copy the published output into a `JRay` subfolder of your Jellyfin plugins
directory (e.g. `~/.local/share/jellyfin/plugins/JRay/` on Linux, or
`%LOCALAPPDATA%\jellyfin\plugins\JRay\` on Windows).
3. Restart Jellyfin.
4. Configure JRay in Dashboard → Plugins → JRay.
3. A tasks which creates the necessary plugin directory and a sub-folder for the specific plugin. The plugin directory is located below the [data directory](https://jellyfin.org/docs/general/administration/configuration.html) of the Jellyfin Server. As an example, the following path can be used for the bookshelf plugin: `$HOME/.local/share/jellyfin/plugins/Bookshelf/`
```jsonc
{
// Ensure the plugin directory exists before trying to use it
"label": "make-plugin-dir",
"type": "shell",
"command": "mkdir",
"args": [
"-Force",
"-Path",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
```
## Technical Architecture
4. A tasks which copies the plugin dll which has been built in step 2.1. The file is copied into it's specific plugin directory within the server's plugin directory.
### Directory Structure
```jsonc
{
// Copy the plugin dll to the jellyfin plugin install path
// This command copies every .dll from the build directory to the plugin dir
// Usually, you probablly only need ${config:pluginName}.dll
// But some plugins may bundle extra requirements
"label": "copy-dll",
"type": "shell",
"command": "cp",
"args": [
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
```
```
Jellyfin.Plugin.JRay/
├── Configuration/
│ ├── PluginConfiguration.cs # Suffix, cache TTL, overlay toggle
│ └── configPage.html # Dashboard config page (embedded resource)
├── Controllers/
│ ├── ActorsController.cs # /Timeline and /jray?t= read endpoints
│ ├── TruthController.cs # PUT/DELETE managed truth data
│ ├── TasksController.cs # /Tasks/Pending work discovery (policy-aware)
│ ├── PolicyController.cs # /Policy/Rules prioritise/ignore CRUD
│ ├── CoverageController.cs # /Coverage overview + genre/series/item pickers
│ └── WebController.cs # /ClientScript overlay script
├── Models/
│ ├── TruthFile.cs # Root truth-file schema (schema_version 1)
│ ├── TruthActor.cs # Per-actor entry with scene windows
│ ├── ActorAtTime.cs # Actor entry in the "context at t" envelope
│ ├── JRayContext.cs # Extensible "context at time t" envelope
│ ├── PendingExtractionItem.cs # Item descriptor for the work-discovery API
│ ├── MediaPolicyRule.cs # A prioritise/ignore rule (+ PolicyScope/PolicyAction)
│ ├── CoverageReport.cs # Coverage overview (+ CoverageCounts / breakdown rows)
│ └── PickerOption.cs # value/label option for the config-page pickers
├── Services/
│ ├── Interfaces/
│ │ ├── ITruthDataService.cs
│ │ ├── IManagedTruthStore.cs
│ │ └── IMediaPolicyStore.cs
│ ├── TruthDataService.cs # Resolves + caches truth (managed > sidecar)
│ ├── ManagedTruthStore.cs # Storage for pushed/managed truth data
│ ├── MediaPolicyStore.cs # Persists prioritise/ignore rules (policy.json)
│ ├── PolicyResolver.cs # Resolves an item's effective rule (item>series>genre)
│ └── WebClientPatchService.cs # Injects/removes overlay script in index.html
├── Web/
│ └── jray-overlay.js # Pause-overlay client script (embedded resource)
├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Plugin entry point; applies web patch on load
```
## Licensing
### Key Components
Licensing is a complex topic. This repository features a GPLv3 license template that can be used to provide a good default license for your plugin. You may alter this if you like, but if you do a permissive license must be chosen.
1. **Truth Data Service** (`TruthDataService`): resolves truth data for an item —
managed (pushed) data takes precedence over a sidecar file — and caches the
result in memory with the configured TTL.
2. **Managed Truth Store** (`ManagedTruthStore`): stores truth data pushed via the
API, independently of the media library filesystem.
3. **Controllers**: REST endpoints for reading (`ActorsController`), pushing
(`TruthController`), work discovery (`TasksController`), and serving the
overlay script (`WebController`).
4. **Web Client Patch Service** (`WebClientPatchService`): injects (or removes) the
`<script>` tag in the web client's `index.html`, marked with `<!-- jray-overlay -->`
so it's idempotent. Re-applied whenever configuration changes.
5. **Overlay Script** (`jray-overlay.js`): listens for the player's pause event,
calls `jray?t=`, and renders the scene's cast.
Due to how plugins in Jellyfin work, when your plugin is compiled into a binary, it will link against the various Jellyfin binary NuGet packages. These packages are licensed under the GPLv3. Thus, due to the nature and restrictions of the GPL, the binary plugin you get will also be licensed under the GPLv3.
## Important Notes
If you accept the default GPLv3 license from this template, all will be good. However if you choose a different license, please keep this fact in mind, as it might not always be obvious that an, e.g. MIT-licensed plugin would become GPLv3 when compiled.
- **Web client overlay is a patch, not a hook.** Jellyfin has no plugin hook for
player UI, so JRay edits the web client's `index.html` directly. This generally
survives across restarts but may need re-applying after a Jellyfin web update;
toggling the overlay setting off and on re-runs the patch.
- **Truth data is produced offline.** JRay does not run face detection itself — it
consumes truth files from the
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
pipeline. No extraction = no overlay.
- **Schema version 1 only.** JRay accepts `schema_version: 1`. The schema may
change in future releases; bump-aware clients should send the version they
produced.
- **Remote path mapping.** Workers pushing truth match items by `Path`, so they
must see media at the same path Jellyfin does (translate paths first if mounts
differ).
Please note that this also means making "proprietary", source-unavailable, or otherwise "hidden" plugins for public consumption is not permitted. To build a Jellyfin plugin for distribution to others, it must be under the GPLv3 or a permissive open-source license that can be linked against the GPLv3.
## Contributing
Contributions welcome! This project is hosted on a self-hosted
[Gitea](https://gitea.tourolle.paris/dtourolle/jRay) instance.
**You don't need a separate account** — you can sign in with your existing GitHub
account. On the [sign-in page](https://gitea.tourolle.paris/user/login), choose
**"Sign in with GitHub"** to register and log in via GitHub OAuth. Once signed in,
you can:
- **Raise issues** — report bugs or request features on the
[issue tracker](https://gitea.tourolle.paris/dtourolle/jRay/issues).
- **Contribute code** — fork the repository, push a branch, and open a pull request.
## License
JRay is licensed under the **GNU General Public License v3.0**. See the
[LICENSE](LICENSE) file for details.
Because Jellyfin plugins link against the GPLv3-licensed Jellyfin NuGet packages,
the compiled plugin is necessarily GPLv3 as well.
## Acknowledgments
This plugin was developed partly using
[Claude Code](https://docs.anthropic.com/en/docs/claude-code) by Anthropic.
Built on the [Jellyfin plugin template](https://github.com/jellyfin/jellyfin-plugin-template)
and powered by the
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
pipeline.
## References
- [Jellyfin Plugin Documentation](https://jellyfin.org/docs/general/server/plugins/)
- [scene-actor-extraction pipeline](https://github.com/dtourolle/scene-actor-extraction)
- [JRay truth file specification](SPEC.md)
+867 -134
View File
File diff suppressed because it is too large Load Diff
+6 -1
View File
@@ -19,4 +19,9 @@ dotnet_configuration: "Release"
dotnet_framework: "net9.0"
project: "Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj"
changelog: >
Initial scaffold
The pause overlay is now served through the File Transformation plugin, which
rewrites the web client's index.html as it is sent. JRay no longer edits that
file on disk and there is no fallback: without File Transformation the overlay
is disabled and every other feature works normally. Any stale on-disk patch
left by an earlier JRay is removed on startup, and the plugin's configuration
page now reports whether the dependency is satisfied.
+288
View File
@@ -0,0 +1,288 @@
# Audio signature and alignment — how it works
How a manifest fetched from a public server is checked against, and shifted onto,
your own copy of a film.
Requirements: `JR-042``JR-045`, `JR-047`. Construction is owned by
[`JRay-public-server/SPEC.md` §3](../../JRay-public-server/SPEC.md); this document
is the mechanism end to end.
---
## The problem
A manifest says "Peter Capaldi is on screen from 42:10 to 42:38". Your file may
not agree, because releases of the same film are trimmed differently — a
distributor logo here, a longer certificate card there. Thirty seconds of extra
head material makes every window in the manifest wrong by thirty seconds.
The old defence was the runtime tier: accept a manifest only if the runtimes
agree within ±2 s. That rejects exactly the releases it should be fixing, and
accepts anything that happens to be the same length.
A **content-derived signature** answers the question the exchange actually needs
*do these timings apply to this media?* — and, when the answer is "yes but
shifted", says by how much.
Deliberately, it does not answer *what file is this?* There is no file hash
anywhere in the system; the tier was withdrawn on legal grounds. The signature
identifies a **cut**, so two different encodes of the same edit agree.
---
## The signature
120 seconds from the **centre** of the media — the head and tail are the least
content-specific parts of a release, being logos and credits.
```
decode centre window runtime/2 ± 60 s
downmix mono
resample 11025 Hz
STFT 4096-sample frame, 1024-sample hop (~93 ms), Hann
band 3003000 Hz, 32 logarithmically spaced bands
per frame peak band index (5 bits) + energy class (2 bits)
-> one byte, bit 7 always clear
result 1288 bytes, base64, prefixed "v1:"
```
Peak bins are used because they survive lossy re-encoding, loudness
normalisation and channel-layout differences, where absolute magnitudes do not.
Same principle as Chromaprint/AcoustID, but self-contained: no external service
is queried, so no lookup leaks which titles an instance holds.
Bit 7 being always clear is not decoration. It is what makes an arbitrary byte
string *not* a valid signature, which is what the server validates on upload, and
what stops the field being usable as a payload channel. The length is fixed by
the construction rather than merely bounded, for the same reason — a caller
cannot choose it, so it cannot become a variable-size container.
### Why the parameters are written down twice
The specification's prose does not determine a byte stream. Two people
implementing "32 log-spaced bands, take the peak" will disagree on at least:
- `float` or `double` — the fixture has frames whose two strongest bands are
within 1.3% of each other, so `float` is not sufficient;
- periodic Hann (`/N`) or symmetric (`/(N-1)`);
- whether a band's value is the **mean** or the **sum** of its magnitudes (sum
favours wide high bands over narrow low ones);
- which way an `argmax` breaks ties;
- whether the frame count is `1 + (n - 4096)/1024` or something that wobbles
with the resampler tail.
Every one of those is pinned at the top of
[`AudioSignature.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs) and
matched in the C++ producer. A signature that differs in any parameter simply
does not match, which defeats the entire point of having one.
### Two implementations, proven equal
The extraction pipeline (C++, `IR-004`) computes this for files it processes
locally. The plugin (C#, `JR-042`) computes it for the files the pipeline never
sees. Both must agree exactly.
That is a *checked* claim, not an aspiration. `fixtures/audio/` holds three files
byte-identical to the extraction repo's copies:
| File | What it pins |
|---|---|
| `jray_audio_v1_tone.flac` | 120 s of tones stepping through all 32 bands, amplitudes walking a golden-ratio sequence so all four energy classes appear |
| `jray_audio_v1_golden.json` | The expected signature, the band→FFT-bin table, and checksums of the decoded PCM |
| `make_fixture.py` | Regenerates the media from plain arithmetic — no numpy, ports to any language in ~20 lines |
The binding check regenerates the fixture PCM from `make_fixture.py`'s arithmetic
and verifies it against the recorded `s16le`/`f32le` checksums **before** making
any DSP claim. So it runs on a CI host with no codec at all, and a decode
divergence stays distinguishable from a DSP one. The two tests that drive real
FFmpeg self-skip without a binary — a check that skips is not a check, so it is
never the only cover for a claim.
---
## Matching, and the offset
Two signatures are compared by sliding one against the other:
```
for offset in -600 .. +600 frames: # ±56 s
score(offset) = fraction of overlapping frames whose peak band matches
best = argmax score
```
| Score | Meaning |
|---|---|
| `≥ 0.85` | Same cut. `audio` tier. The offset applies |
| `0.60 0.85` | Possibly the same cut, degraded audio. `loose` tier, surfaced as a caveat |
| `< 0.60` | Different content. No match |
Only the **peak band** is scored. The energy class is the coarser and less
re-encoding-stable of the two fields, and the specification's rule names the peak
bin alone.
Speed-differing releases (a PAL 4% speed-up) are not a constant offset and are
correctly rejected by the score threshold rather than mis-aligned.
### The offset has two terms
This is the part that is easy to get wrong. Both windows are centred on **their
own file's** midpoint, so when the runtimes differ the two windows do not start
at the same point in the content:
```
offset = (local_window_start - manifest_window_start) + slide × 1024/11025
└────────── anchor difference ──────────┘ └──── recovered ────┘
```
A release carrying 40 s of extra head material recovers **20 s from each term**.
Using the slide alone would be wrong by half the runtime difference on every
shifted release. The specification's pseudocode describes only the slide, because
it is written from the server's position, where both signatures are being
compared against one stored manifest.
### One parameter that is not from the specification
An alignment must overlap by at least **64 frames** (~6 s) before its score
counts. Without a floor the extreme offsets compare a handful of frames, where a
chance agreement scores 1.0 and beats the true alignment. It never binds on the
real case: two full-length signatures still overlap by 688 frames at the widest
offset. It is marked as a local addition in the code.
---
## What happens when a manifest arrives
`ManifestController.FetchItem``ManifestAligner.AlignAsync`, before anything is
stored:
```
1. Server returns a manifest, a tier, and an offset.
2. Does the manifest carry cut.audio_signature? no -> use the server's offset
Are signatures enabled in configuration? no -> use the server's offset
3. Decode this file's centre window, compute its signature.
4. Compare the two.
match -> apply the LOCAL offset and tier
no match -> apply the server's offset, record the disagreement
not comparable -> apply the server's offset
5. Apply the offset to every window, once, at store time.
6. Write the truth file, and the alignment beside it.
```
### Why the local answer wins
**The server has never seen your file.** Its offset is a claim about a runtime it
was told — at best a runtime-difference inference. A local alignment compares the
manifest's own signature against the media the windows will actually be drawn
over, which is the authoritative comparison. It also needs no round trip, so no
signature ever leaves the instance.
This is what the specification means by matching being "a consumer concern": the
server never rewrites a manifest, so **one stored manifest serves every trim of
the same cut**, and each client shifts it onto its own timebase.
### Degradation, never failure
A signature is an enhancement to cut matching. A missing one costs a tier and
**must never be able to break a fetch**. Every one of these stores the manifest
on the server's terms:
- signatures switched off in configuration (they are opt-in — the decode costs a
second or two of I/O per item);
- the item is under 120 s, so the window underflows and there is no signature to
compute (`JR-044`);
- the manifest carried no signature;
- the manifest's signature is `v2:` from a future producer — **refused, not
parsed** (`JR-045`), because scoring an unknown DSP chain as v1 would be a
confident wrong answer where declining is a correct one;
- no FFmpeg binary, no audio stream, or a decode error.
### "Un-comparable" and "does not match" are different
The distinction matters more than it looks. A 90-second extra is not content that
disagrees with its manifest — it is content that could not be compared. Reporting
the first as the second would show a user a scary warning about the wrong thing.
A genuine mismatch — both signatures present, both valid, both items long enough,
and the best alignment still below 0.60 — is the strongest available hint that a
manifest describes different content. It is **still not a failure**: the audio may
legitimately differ, a different language track being the obvious case. So the
manifest is stored on the server's terms and the disagreement is surfaced as a
caveat, which outranks the tier's own caveat because it is the stronger
statement.
---
## What gets stored
The offset is applied **once**, at store time, so the stored windows are always
in your file's own timebase and no read path needs offset awareness (`JR-030`).
That makes the offset unrecoverable afterwards — the windows look native — which
is why the alignment is recorded beside the truth file:
```json
"alignment": {
"source": "Local",
"tier": "Audio",
"offset_sec": 40.0,
"score": 0.97,
"offset_frames": 215,
"local_signature": "v1:AAAAA…",
"server_offset_sec": 0.0,
"server_tier": "Runtime"
}
```
`local_signature` is kept so a **later fetch aligns for free** — the decode is
the expensive half and the reason signatures are opt-in. It never leaves the
instance: provenance is stored beside the truth file, not inside it, and
contribution strips provenance entirely (`JR-034`).
The truth file itself is untouched by any of this. Injecting fields would mean
the bytes served back are not the bytes the producer wrote, which is the property
`JR-004` turns on.
---
## Where the code is
| Concern | File |
|---|---|
| The DSP | [`Services/AudioSignature.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs) |
| The decode | [`Services/AudioSignatureService.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs) |
| Reading and matching | [`Services/AudioSignatureMatcher.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs) |
| The fetch-path decision | [`Services/ManifestAligner.cs`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs) |
| What is recorded | [`Models/TruthAlignment.cs`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs) |
| Server-side validation | `JRay-public-server/src/validate.rs`, `validate_audio_signature` |
| C++ producer | `scene-actor-extraction/src/audio_signature.{hpp,cpp}` |
Tests: `AudioSignatureTests` (UT-038 … UT-044), `AudioSignatureMatcherTests`
(UT-045 … UT-052), `ManifestAlignerTests` (UT-053 … UT-057).
## Verifying the cross-repo claim by hand
```sh
# C# side
cd jRay && dotnet test Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj
# C++ side, against the same fixture
cd scene-actor-extraction/build && python3 -c "
import json, sae_audio
g = json.load(open('../tests/fixtures/audio/jray_audio_v1_golden.json'))
print(sae_audio.compute_signature('../tests/fixtures/audio/jray_audio_v1_tone.flac') == g['signature'])
"
# and that the fixtures really are the same bytes
md5sum jRay/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_golden.json \
scene-actor-extraction/tests/fixtures/audio/jray_audio_v1_golden.json
```
## Not built yet
- **Matching against a server's catalogue.** `POST /manifests/search` and the
`audio` tier server-side are `UR-009`, still in progress. The sequencing is
deliberate — accumulate signatures first, enable matching once coverage is
useful. Nothing here depends on it: alignment works from the signature the
manifest already carries.
- **Contribution.** Uploads do not yet attach a signature (`JR-034`).
- **Re-using the stored signature.** It is written but not yet read back on a
second fetch, so today every fetch decodes.
+426
View File
@@ -0,0 +1,426 @@
# 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 3003000 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 |
> **Outstanding: §3's score gained ±1 frame of tolerance and this matcher has
> not.** `AudioSignatureMatcher` implements the exact-frame rule §3 carried
> until `JRay-public-server` UR-009 landed. The change was
> `scene-actor-extraction` VR-014's measurement: over 40 correctly recovered
> offsets on real film audio the exact rule scored 27 of them below 0.85 and
> demoted them to `loose`, because the two windows are cut on their own file's
> frame grid and those grids do not coincide. With ±1 frame all 40 reach
> `audio` and the strongest false match is unmoved at 0.16.
>
> **Nothing is misaligned by the divergence** — the offset the matcher recovers
> is unaffected, and JR-047 makes the local answer supersede the server's — but
> the plugin will label as `loose` alignments the server calls `audio`, which is
> a caveat shown to a user for a match that is not in doubt. JR-044's matcher
> and [`audio-alignment.md`](audio-alignment.md)'s scoring table both need the
> revised rule. Until then this repo implements a superseded version of §3.
## 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).
+826
View File
@@ -0,0 +1,826 @@
# Requirements traceability matrix
<!-- GENERATED FILE - do not edit by hand. -->
<!-- Regenerate: scripts/traceability/traceability-gate.sh -->
**Generated:** 2026-07-31T15:05:02+00:00
Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, static`).
## Summary
| Metric | Value |
|---|---|
| Source files scanned | 78 |
| TRACES tags found | 48 |
| EXCEPTION tags found | 0 |
| Requirements defined | 47 |
| Requirements covered | 38 |
| **Coverage** | **80.9%** (38/47) |
| Coverage of CI-executable scope | 84.4% (38/45) |
| Tagged but unexecuted in CI | 1 |
| Orphan tags | 0 |
### By type
| Type | Covered | Tagged but unexecuted | Defined |
|---|---|---|---|
| JR | 38 | 1 | 47 |
- **UT** tags present (separate taxonomy, not counted in coverage): UT-001, UT-002, UT-003, UT-004, UT-005, UT-007, UT-008, UT-009, UT-010, UT-011, UT-012, UT-013, UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-024, UT-025, UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037, UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044, UT-045, UT-046, UT-047, UT-048, UT-049, UT-050, UT-051, UT-052, UT-053, UT-054, UT-055, UT-056, UT-057
- **PR** tags present (separate taxonomy, not counted in coverage): PR-001, PR-003, PR-004, PR-005, PR-006
- **SR** tags present (separate taxonomy, not counted in coverage): SR-001, SR-002, SR-003, SR-004, SR-005
## Not executable in CI
These requirements have no verification tier this repo's CI host can run, so a tag on them is evidence of *intent*, not of verification. They are never counted as covered.
| ID | Tiers | Tagged in source | Requirement |
|---|---|---|---|
| JR-020 | T4 | yes | Pause overlay: injected client script queries `jray?t=` and renders t… |
| JR-040 | T4 | no | The config page states plainly that **each configured server multipli… |
**Tagged but unexecuted:** JR-020 — a test exists and is tagged, but this CI host cannot run it. Report those runs separately.
## Orphan tags
A tag naming an ID `requirements.md` does not define. This is what renumbering produces, and what a typo produces.
_None._
## Requirements tracing up to nothing
A register row whose `Traces to` cell names no parent. Work serving no stated goal is how scope creeps in, and it is invisible unless something looks.
_None._
## Recorded exceptions
Deliberate, documented departures from an invariant (`EXCEPTION: XX-nnn <reason>`). Reported separately and never counted as coverage — an exception is a decision to be reviewed, not evidence a requirement is met.
_None._
## Register
| ID | Status | Tier | Traces to | Trace state | Tagged in | Requirement |
|---|---|---|---|---|---|---|
| JR-001 | In Progress | static | SR-003 | covered | `Jellyfin.Plugin.JRay/Models/TruthFile.cs` | The truth-file format is normatively defined here; other repos refere… |
| JR-002 | **Done** (UT-029…03… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs`, `Jellyfin.Plugin.JRay/Models/TruthCut.cs`, `Jellyfin.Plugin.JRay/Models/TruthExtraction.cs`, `Jellyfin.Plugin.JRay/Models/TruthFile.cs`, `Jellyfin.Plugin.JRay/Models/TruthScene.cs` | `schema_version: 2` shape — `extraction.*` provenance block, `cut.*` … |
| JR-003 | **Done** (UT-033…03… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs`, `Jellyfin.Plugin.JRay/Controllers/TruthController.cs`, `Jellyfin.Plugin.JRay/Services/TruthSchema.cs` | Reject an unknown `schema_version`, never guess. **Flag day: v2 only*… |
| JR-004 | **Done** (UT-021, U… | T1 | **SR-002** | covered | `Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs`, `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Models/TruthActor.cs`, `Jellyfin.Plugin.JRay/Models/TruthScene.cs`, `Jellyfin.Plugin.JRay/Services/PresenceLookup.cs` | A window is a **scene-membership claim**, not a recognition event — n… |
| JR-005 | **Done** (UT-016…01… | T1 | **SR-002** | covered | `Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs`, `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Models/ActorInScene.cs`, `Jellyfin.Plugin.JRay/Services/PresenceLookup.cs`, `Jellyfin.Plugin.JRay/Web/jray-overlay.js` | Query semantics: actor present at `t` if any window contains `t`; pre… |
| JR-006 | **Done** (UT-020, U… | T1 | SR-002 | covered | `Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs`, `Jellyfin.Plugin.JRay/Services/PresenceLookup.cs` | Read path holds up under **numerous** windows — no assumption of a ha… |
| JR-007 | Done | T1 | SR-001 | covered | `Jellyfin.Plugin.JRay/Models/TruthActor.cs` | Identity is public identifiers: prefer `jellyfin_id` locally, else re… |
| JR-008 | Done | T1 | PR-001 | covered | `Jellyfin.Plugin.JRay/Services/TruthDataService.cs` | Discover a sidecar truth file beside the media, by configurable suffix |
| JR-009 | Done | T2 | PR-004 | covered | `Jellyfin.Plugin.JRay/Controllers/TruthController.cs`, `Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs` | Accept truth data pushed by a remote worker (`PUT`/`DELETE`), admin k… |
| JR-010 | **Done** (UT-024…02… | T1 | PR-001 | covered | `Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs`, `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Models/TruthProvenance.cs`, `Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs`, `Jellyfin.Plugin.JRay/Services/TruthDataService.cs` | Precedence: managed truth (pushed **or** fetched) overrides a sidecar… |
| JR-011 | Done | T1 | PR-001 | covered | `Jellyfin.Plugin.JRay/Services/TruthDataService.cs` | Loaded truth is cached; any write invalidates the item's cache entry … |
| JR-012 | Done | T2 | PR-001 | covered | `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs` | `GET .../Timeline` returns the full truth file for an item |
| JR-013 | Done | T2 | PR-001 | covered | `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs` | `GET .../jray?t=` returns an **extensible** context envelope; consume… |
| JR-014 | Done | T2 | PR-004 | covered | `Jellyfin.Plugin.JRay/Controllers/ActorsController.cs`, `Jellyfin.Plugin.JRay/Controllers/PolicyController.cs`, `Jellyfin.Plugin.JRay/Controllers/TruthController.cs`, `Jellyfin.Plugin.JRay/Controllers/WebController.cs` | Authorisation: reads need an authenticated user, admin routes need th… |
| JR-015 | Done | T2 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/TasksController.cs` | `Tasks/Pending` serves a random sample of items with no truth data, s… |
| JR-016 | **Done** (UT-007…01… | T1 | PR-003 | covered | `Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs`, `Jellyfin.Plugin.JRay/Controllers/PolicyController.cs`, `Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs`, `Jellyfin.Plugin.JRay/Services/PolicyResolver.cs` | Prioritise/ignore rules scoped `Genre` / `Series` / `Item`; **most sp… |
| JR-017 | Done | T1 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/TasksController.cs` | Rules steer **work discovery only** — never the overlay or the read e… |
| JR-018 | Done | T1 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/CoverageController.cs` | Coverage report by media type and genre; ignored items leave the perc… |
| JR-019 | Done | T2 | PR-003 | covered | `Jellyfin.Plugin.JRay/Controllers/CoverageController.cs` | Picker endpoints (genres, series, item search) populate the rule edit… |
| JR-020 | Done | T4 | **PR-001** | tagged, unexecuted | `Jellyfin.Plugin.JRay/Controllers/WebController.cs`, `Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs`, `Jellyfin.Plugin.JRay/Web/jray-overlay.js` | Pause overlay: injected client script queries `jray?t=` and renders t… |
| JR-021 | **Done** | static | PR-004 | covered | `Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs`, `scripts/checks/no-index-injection.sh` | **jRay never injects into `index.html` on disk.** File Transformation… |
| JR-022 | **Done** (UT-001…00… | T1 | PR-004 | covered | `Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs`, `Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs` | Migration: remove any on-disk patch left by an earlier jRay, identifi… |
| JR-023 | **Done** (UT-012…01… | T1, T4 | PR-004 | covered | `Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs`, `Jellyfin.Plugin.JRay/Controllers/StatusController.cs`, `Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs` | Absent the dependency, disable **only** the overlay and say so in the… |
| JR-024 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Web/jray-overlay.js` | Actor names and all server-supplied strings render as **text, never m… |
| JR-025 | Done | T1 | PR-006 | covered | `Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs`, `Jellyfin.Plugin.JRay/Controllers/ManifestController.cs`, `Jellyfin.Plugin.JRay/Models/Jmanifest.cs`, `Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs`, `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | Query an **ordered list** of servers; first result clearing the confi… |
| JR-026 | Planned | T1 | PR-006 | untagged | - | For a series, first-match applies per **episode** — later servers are… |
| JR-027 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Models/Jmanifest.cs`, `Jellyfin.Plugin.JRay/Services/ManifestValidator.cs` | Treat **every** server as untrusted, including the default: re-valida… |
| JR-028 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB… |
| JR-029 | Done | T1 | SR-004 | covered | `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | HTTPS required for non-loopback servers; certificate validation must … |
| JR-030 | Done | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay/Services/ManifestConverter.cs`, `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | Apply an `audio`-tier `offset` to **every** window before storing — s… |
| JR-031 | Planned | T2 | PR-006 | covered | `Jellyfin.Plugin.JRay/Controllers/ManifestController.cs` | Fetch endpoints: item fetch, series bundle fetch, per-server status, … |
| JR-032 | Planned | T1 | PR-006 | untagged | - | Identify is **never automatic** — storing a candidate is a separate c… |
| JR-033 | Planned | T1 | PR-006 | untagged | - | Scheduled sweep over items lacking truth data, using the **batch** `e… |
| JR-034 | Planned | T1 | PR-005 | untagged | - | Contribution strips `movie` and `jellyfin_id`, attaches identity from… |
| JR-035 | Planned | T1 | PR-006 | untagged | - | Uploads set `Expect: 100-continue`, so a rejection lands before a bun… |
| JR-036 | In Progress | T1 | PR-006 | covered | `Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs`, `Jellyfin.Plugin.JRay/Models/TruthProvenance.cs` | Minimum accepted match tier is configurable; a `loose` match surfaces… |
| JR-037 | Done | T1 | PR-006 | covered | `Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs` | A server that is unreachable or failing is skipped on a short timeout… |
| JR-038 | Done | T1 | **PR-005** | covered | `Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs` | Every exchange feature is **opt-in and off by default**, including th… |
| JR-039 | Planned | T1 | **PR-005** | untagged | - | No library-wide inventory in one request: batch `exists` capped at 10… |
| JR-040 | Planned | T4 | **PR-005** | untagged | - | The config page states plainly that **each configured server multipli… |
| JR-041 | Done | static | **SR-005** | covered | `scripts/checks/no-gallery-data.sh` | The plugin never fetches, stores, or transmits gallery data — referen… |
| JR-042 | **Done** (UT-039…04… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignature.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs` | Compute the signature **exactly** per server spec §3, using the FFmpe… |
| JR-043 | **Done** (UT-038, U… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs` | Golden-vector fixture **shared with the extraction repo**, proving th… |
| JR-044 | **Done** (UT-045, U… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs` | Media shorter than 120 s: emit no signature and apply no sync offset … |
| JR-045 | **Done** (UT-047…04… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs` | Emit and honour the signature's own `v1:` prefix, so a DSP change is … |
| JR-046 | **TBD** | T2, T4 | [system §4](../scripts/vendor/jray-proj… | untagged | - | Review UI for unidentified track clusters: show context crops, pick f… |
| JR-047 | **Done** (UT-053…05… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs`, `Jellyfin.Plugin.JRay/Models/TruthAlignment.cs`, `Jellyfin.Plugin.JRay/Services/ManifestAligner.cs` | **A fetched manifest is aligned against the local file before its win… |
## Detailed mapping
### JR-001
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Models/TruthFile.cs:26`](../Jellyfin.Plugin.JRay/Models/TruthFile.cs#L26) — `public class TruthFile`
### JR-002
**Locations:** 5
- [`Jellyfin.Plugin.JRay/Models/TruthCut.cs:15`](../Jellyfin.Plugin.JRay/Models/TruthCut.cs#L15) — `public class TruthCut`
- [`Jellyfin.Plugin.JRay/Models/TruthExtraction.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthExtraction.cs#L21) — `public class TruthExtraction`
- [`Jellyfin.Plugin.JRay/Models/TruthFile.cs:26`](../Jellyfin.Plugin.JRay/Models/TruthFile.cs#L26) — `public class TruthFile`
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### JR-003
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/TruthSchema.cs:35`](../Jellyfin.Plugin.JRay/Services/TruthSchema.cs#L35) — `public static class TruthSchema`
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### JR-004
**Locations:** 5
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### JR-005
**Locations:** 5
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Models/ActorInScene.cs:15`](../Jellyfin.Plugin.JRay/Models/ActorInScene.cs#L15) — `public class ActorInScene`
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### JR-006
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### JR-007
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
### JR-008
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
### JR-009
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs:21`](../Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs#L21) — `public sealed class ManagedTruthStore : IManagedTruthStore`
### JR-010
**Locations:** 5
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Models/TruthProvenance.cs:38`](../Jellyfin.Plugin.JRay/Models/TruthProvenance.cs#L38) — `public class TruthProvenance`
- [`Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs:21`](../Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs#L21) — `public sealed class ManagedTruthStore : IManagedTruthStore`
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
### JR-011
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
### JR-012
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
### JR-013
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
### JR-014
**Locations:** 4
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Controllers/PolicyController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/PolicyController.cs#L24) — `public class PolicyController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Controllers/WebController.cs:21`](../Jellyfin.Plugin.JRay/Controllers/WebController.cs#L21) — `public class WebController : ControllerBase`
### JR-015
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Controllers/TasksController.cs:34`](../Jellyfin.Plugin.JRay/Controllers/TasksController.cs#L34) — `public class TasksController : ControllerBase`
### JR-016
**Locations:** 4
- [`Jellyfin.Plugin.JRay/Controllers/PolicyController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/PolicyController.cs#L24) — `public class PolicyController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs:20`](../Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs#L20) — `public sealed class MediaPolicyStore : IMediaPolicyStore`
- [`Jellyfin.Plugin.JRay/Services/PolicyResolver.cs:14`](../Jellyfin.Plugin.JRay/Services/PolicyResolver.cs#L14) — `public static class PolicyResolver`
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
### JR-017
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Controllers/TasksController.cs:34`](../Jellyfin.Plugin.JRay/Controllers/TasksController.cs#L34) — `public class TasksController : ControllerBase`
### JR-018
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Controllers/CoverageController.cs:30`](../Jellyfin.Plugin.JRay/Controllers/CoverageController.cs#L30) — `public class CoverageController : ControllerBase`
### JR-019
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Controllers/CoverageController.cs:30`](../Jellyfin.Plugin.JRay/Controllers/CoverageController.cs#L30) — `public class CoverageController : ControllerBase`
### JR-020
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Controllers/WebController.cs:21`](../Jellyfin.Plugin.JRay/Controllers/WebController.cs#L21) — `public class WebController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs:31`](../Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs#L31) — `public static class FileTransformationRegistration`
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
### JR-021
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs:26`](../Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs#L26) — `public static class WebClientPatchService`
- [`scripts/checks/no-index-injection.sh:11`](../scripts/checks/no-index-injection.sh#L11) — `Unknown`
### JR-022
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs:26`](../Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs#L26) — `public static class WebClientPatchService`
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
### JR-023
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Controllers/StatusController.cs:20`](../Jellyfin.Plugin.JRay/Controllers/StatusController.cs#L20) — `public class StatusController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs:31`](../Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs#L31) — `public static class FileTransformationRegistration`
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
### JR-024
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
### JR-025
**Locations:** 5
- [`Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs:75`](../Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs#L75) — `public class ManifestServer`
- [`Jellyfin.Plugin.JRay/Controllers/ManifestController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/ManifestController.cs#L24) — `public class ManifestController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Models/Jmanifest.cs:21`](../Jellyfin.Plugin.JRay/Models/Jmanifest.cs#L21) — `public class Jmanifest`
- [`Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs:16`](../Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs#L16) — `public interface IManifestExchangeClient`
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
### JR-027
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Models/Jmanifest.cs:21`](../Jellyfin.Plugin.JRay/Models/Jmanifest.cs#L21) — `public class Jmanifest`
- [`Jellyfin.Plugin.JRay/Services/ManifestValidator.cs:26`](../Jellyfin.Plugin.JRay/Services/ManifestValidator.cs#L26) — `public static class ManifestValidator`
### JR-028
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:282`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L282) — `private static async Task<string?> ReadCappedAsync(`
### JR-029
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
### JR-030
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Services/ManifestConverter.cs:11`](../Jellyfin.Plugin.JRay/Services/ManifestConverter.cs#L11) — `public static class ManifestConverter`
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
### JR-031
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Controllers/ManifestController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/ManifestController.cs#L24) — `public class ManifestController : ControllerBase`
### JR-036
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs:16`](../Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs#L16) — `public class PluginConfiguration : BasePluginConfiguration`
- [`Jellyfin.Plugin.JRay/Models/TruthProvenance.cs:38`](../Jellyfin.Plugin.JRay/Models/TruthProvenance.cs#L38) — `public class TruthProvenance`
### JR-037
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
### JR-038
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs:16`](../Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs#L16) — `public class PluginConfiguration : BasePluginConfiguration`
### JR-041
**Locations:** 1
- [`scripts/checks/no-gallery-data.sh:14`](../scripts/checks/no-gallery-data.sh#L14) — `Unknown`
### JR-042
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Services/AudioSignature.cs:49`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs#L49) — `public static class AudioSignature`
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs:39`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs#L39) — `public class AudioSignatureService`
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### JR-043
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### JR-044
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher`
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### JR-045
**Locations:** 2
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher`
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### JR-047
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Models/TruthAlignment.cs:17`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs#L17) — `public class TruthAlignment`
- [`Jellyfin.Plugin.JRay/Services/ManifestAligner.cs:36`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs#L36) — `public class ManifestAligner`
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
### PR-001
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Controllers/WebController.cs:21`](../Jellyfin.Plugin.JRay/Controllers/WebController.cs#L21) — `public class WebController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Models/TruthProvenance.cs:38`](../Jellyfin.Plugin.JRay/Models/TruthProvenance.cs#L38) — `public class TruthProvenance`
- [`Jellyfin.Plugin.JRay/Services/TruthDataService.cs:25`](../Jellyfin.Plugin.JRay/Services/TruthDataService.cs#L25) — `public sealed class TruthDataService : ITruthDataService`
### PR-003
**Locations:** 5
- [`Jellyfin.Plugin.JRay/Controllers/CoverageController.cs:30`](../Jellyfin.Plugin.JRay/Controllers/CoverageController.cs#L30) — `public class CoverageController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Controllers/PolicyController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/PolicyController.cs#L24) — `public class PolicyController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Controllers/TasksController.cs:34`](../Jellyfin.Plugin.JRay/Controllers/TasksController.cs#L34) — `public class TasksController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs:20`](../Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs#L20) — `public sealed class MediaPolicyStore : IMediaPolicyStore`
- [`Jellyfin.Plugin.JRay/Services/PolicyResolver.cs:14`](../Jellyfin.Plugin.JRay/Services/PolicyResolver.cs#L14) — `public static class PolicyResolver`
### PR-004
**Locations:** 5
- [`Jellyfin.Plugin.JRay/Controllers/StatusController.cs:20`](../Jellyfin.Plugin.JRay/Controllers/StatusController.cs#L20) — `public class StatusController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs:31`](../Jellyfin.Plugin.JRay/Services/FileTransformationRegistration.cs#L31) — `public static class FileTransformationRegistration`
- [`Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs:21`](../Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs#L21) — `public sealed class ManagedTruthStore : IManagedTruthStore`
- [`Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs:26`](../Jellyfin.Plugin.JRay/Services/WebClientPatchService.cs#L26) — `public static class WebClientPatchService`
- [`scripts/checks/no-index-injection.sh:11`](../scripts/checks/no-index-injection.sh#L11) — `Unknown`
### PR-005
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs:75`](../Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs#L75) — `public class ManifestServer`
- [`Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs:16`](../Jellyfin.Plugin.JRay/Configuration/PluginConfiguration.cs#L16) — `public class PluginConfiguration : BasePluginConfiguration`
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
### PR-006
**Locations:** 4
- [`Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs:75`](../Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs#L75) — `public class ManifestServer`
- [`Jellyfin.Plugin.JRay/Controllers/ManifestController.cs:24`](../Jellyfin.Plugin.JRay/Controllers/ManifestController.cs#L24) — `public class ManifestController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs:16`](../Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs#L16) — `public interface IManifestExchangeClient`
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:32`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L32) — `public class ManifestExchangeClient : IManifestExchangeClient, IDisposable`
### SR-001
**Locations:** 1
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
### SR-002
**Locations:** 7
- [`Jellyfin.Plugin.JRay/Controllers/ActorsController.cs:26`](../Jellyfin.Plugin.JRay/Controllers/ActorsController.cs#L26) — `public class ActorsController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Models/ActorInScene.cs:15`](../Jellyfin.Plugin.JRay/Models/ActorInScene.cs#L15) — `public class ActorInScene`
- [`Jellyfin.Plugin.JRay/Models/TruthActor.cs:20`](../Jellyfin.Plugin.JRay/Models/TruthActor.cs#L20) — `public class TruthActor`
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
- [`Jellyfin.Plugin.JRay/Services/ManifestConverter.cs:11`](../Jellyfin.Plugin.JRay/Services/ManifestConverter.cs#L11) — `public static class ManifestConverter`
- [`Jellyfin.Plugin.JRay/Services/PresenceLookup.cs:27`](../Jellyfin.Plugin.JRay/Services/PresenceLookup.cs#L27) — `public static class PresenceLookup`
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
### SR-003
**Locations:** 13
- [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase`
- [`Jellyfin.Plugin.JRay/Models/Jmanifest.cs:21`](../Jellyfin.Plugin.JRay/Models/Jmanifest.cs#L21) — `public class Jmanifest`
- [`Jellyfin.Plugin.JRay/Models/TruthAlignment.cs:17`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs#L17) — `public class TruthAlignment`
- [`Jellyfin.Plugin.JRay/Models/TruthCut.cs:15`](../Jellyfin.Plugin.JRay/Models/TruthCut.cs#L15) — `public class TruthCut`
- [`Jellyfin.Plugin.JRay/Models/TruthExtraction.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthExtraction.cs#L21) — `public class TruthExtraction`
- [`Jellyfin.Plugin.JRay/Models/TruthFile.cs:26`](../Jellyfin.Plugin.JRay/Models/TruthFile.cs#L26) — `public class TruthFile`
- [`Jellyfin.Plugin.JRay/Models/TruthScene.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthScene.cs#L21) — `public class TruthScene`
- [`Jellyfin.Plugin.JRay/Services/AudioSignature.cs:49`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs#L49) — `public static class AudioSignature`
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher`
- [`Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs:39`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs#L39) — `public class AudioSignatureService`
- [`Jellyfin.Plugin.JRay/Services/ManifestAligner.cs:36`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs#L36) — `public class ManifestAligner`
- [`Jellyfin.Plugin.JRay/Services/ManifestConverter.cs:11`](../Jellyfin.Plugin.JRay/Services/ManifestConverter.cs#L11) — `public static class ManifestConverter`
- [`Jellyfin.Plugin.JRay/Services/TruthSchema.cs:35`](../Jellyfin.Plugin.JRay/Services/TruthSchema.cs#L35) — `public static class TruthSchema`
### SR-004
**Locations:** 3
- [`Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs:282`](../Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs#L282) — `private static async Task<string?> ReadCappedAsync(`
- [`Jellyfin.Plugin.JRay/Services/ManifestValidator.cs:26`](../Jellyfin.Plugin.JRay/Services/ManifestValidator.cs#L26) — `public static class ManifestValidator`
- [`Jellyfin.Plugin.JRay/Web/jray-overlay.js:14`](../Jellyfin.Plugin.JRay/Web/jray-overlay.js#L14) — `function truncate(text, maxLength)`
### SR-005
**Locations:** 1
- [`scripts/checks/no-gallery-data.sh:14`](../scripts/checks/no-gallery-data.sh#L14) — `Unknown`
### UT-001
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
### UT-002
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
### UT-003
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
### UT-004
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
### UT-005
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/WebClientPatchServiceTests.cs#L16) — `public class WebClientPatchServiceTests`
### UT-007
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
### UT-008
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
### UT-009
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
### UT-010
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
### UT-011
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs:14`](../Jellyfin.Plugin.JRay.Tests/PolicyResolverTests.cs#L14) — `public class PolicyResolverTests`
### UT-012
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
### UT-013
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
### UT-014
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
### UT-015
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs:20`](../Jellyfin.Plugin.JRay.Tests/FileTransformationRegistrationTests.cs#L20) — `public class FileTransformationRegistrationTests`
### UT-016
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-017
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-018
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-019
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-020
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-021
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-022
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-023
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs:19`](../Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs#L19) — `public class PresenceLookupTests`
### UT-024
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
### UT-025
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
### UT-026
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
### UT-027
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
### UT-028
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs:21`](../Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs#L21) — `public class ManagedTruthStoreTests : IDisposable`
### UT-029
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-030
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-031
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-032
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-033
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-034
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-035
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-036
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-037
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs:16`](../Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs#L16) — `public class TruthSchemaTests`
### UT-038
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### UT-039
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### UT-040
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### UT-041
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### UT-042
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### UT-043
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### UT-044
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs:32`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureTests.cs#L32) — `public class AudioSignatureTests`
### UT-045
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-046
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-047
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-048
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-049
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-050
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-051
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-052
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests`
### UT-053
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
### UT-054
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
### UT-055
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
### UT-056
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
### UT-057
**Locations:** 1
- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests`
+82 -2
View File
@@ -12,8 +12,88 @@
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "d6f05de428a51573f020c06651245e69",
"timestamp": "2026-06-12T16:53:59Z"
"checksum": "d3419f1867499fbc63fb485fb7c6c143",
"timestamp": "2026-07-31T09:37:36Z"
},
{
"version": "0.0.4",
"changelog": "Release 0.0.4",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.4/jray_0.0.4.0.zip",
"checksum": "cb9613660b3fe3b730f46c9c7f257333",
"timestamp": "2026-07-04T19:47:05Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "a76316397228c9d8b8e38fbccad50390",
"timestamp": "2026-07-04T19:44:35Z"
},
{
"version": "0.0.3",
"changelog": "Release 0.0.3",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.3/jray_0.0.3.0.zip",
"checksum": "da5ded2ee720f7a7f7e15ac092871a91",
"timestamp": "2026-07-04T19:11:39Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "0d1cc8954a1b0557eb5ec6c368bce486",
"timestamp": "2026-07-04T19:10:48Z"
},
{
"version": "0.0.2",
"changelog": "Release 0.0.2",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.2/jray_0.0.2.0.zip",
"checksum": "6969ca24f23e66eaffae0b52638fea14",
"timestamp": "2026-06-12T18:30:35Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "2f8beef54c03cec92a17431615cba604",
"timestamp": "2026-06-12T18:26:36Z"
},
{
"version": "0.0.1",
"changelog": "Release 0.0.1",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.1/jray_0.0.1.0.zip",
"checksum": "6c0e71d77ebac619920cf93a2c330b8f",
"timestamp": "2026-06-12T18:11:18Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "6ed76e7fea217cda914a6610c48d7d30",
"timestamp": "2026-06-12T18:10:35Z"
},
{
"version": "0.0.0",
"changelog": "Release 0.0.0",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.0/jray_0.0.0.0.zip",
"checksum": "fd92458f741b8f7aa0b77da04cbc6a43",
"timestamp": "2026-06-12T17:05:10Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "3650af90fd163042af65cc702d618506",
"timestamp": "2026-06-12T17:04:26Z"
}
]
}
+42
View File
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# JR-041 — the plugin never fetches, stores, or transmits gallery data.
#
# Like JR-021 this is a requirement to *not do* something, so it is verified by
# absence. It mirrors the server's UR-012, which is preserved the same way: there
# is no field capable of carrying an embedding or a crop, and no code that would
# read one.
#
# The plugin's whole contact with identity is public identifiers (JR-007) and
# scene windows (JR-004). A reference face or a 512-d vector arriving here would
# mean SR-005 had been breached upstream, so the check is for any *parse* of one
# — a field name, a property, a type — not merely for network calls.
#
# TRACES: JR-041 | SR-005
set -euo pipefail
cd "$(dirname "$0")/../.."
roots=("Jellyfin.Plugin.JRay")
status=0
# Field and property names that would carry gallery data. Matched as whole words
# so that unrelated identifiers containing them are not false positives.
banned='\b(embedding|embeddings|face_crop|faceCrop|FaceCrop|reference_face|referenceFace|ReferenceFace|mugshot|Mugshot|gallery_vector|galleryVector|descriptor512|Embedding)\b'
if grep -rn --include='*.cs' --include='*.js' -E "$banned" "${roots[@]}"; then
echo "FAIL (JR-041): a gallery-data field or type is referenced in the plugin." >&2
status=1
fi
# A base64 blob is the other shape this could arrive in. The plugin has no
# legitimate reason to decode one: every string it handles is a name, an id, or
# a URL.
if grep -rn --include='*.cs' -E '\bConvert\.FromBase64String\b' "${roots[@]}"; then
echo "FAIL (JR-041): base64 decoding found — the plugin handles no binary payloads." >&2
status=1
fi
if [ "$status" -eq 0 ]; then
echo "OK (JR-041): no gallery-data code path."
fi
exit "$status"
+44
View File
@@ -0,0 +1,44 @@
#!/usr/bin/env bash
# JR-021 — jRay never injects into index.html on disk.
#
# This is a requirement to *not do* something, so it is verified by absence.
# A unit test cannot show that no code path writes the tag; a grep can.
#
# The prohibition is on injection, not on writing: JR-022's migration must write
# to index.html in order to remove a legacy patch. So the check is for code that
# *adds* the script tag, not for File.Write* generally.
#
# TRACES: JR-021 | PR-004
set -euo pipefail
cd "$(dirname "$0")/../.."
src="Jellyfin.Plugin.JRay"
status=0
# The injection is "script tag + marker" written back to the file. The removal
# path also names both, so match on the concatenation that builds a patched
# document rather than on the constants themselves.
if grep -rn --include='*.cs' -E '(ScriptTag|Injected)[[:space:]]*\+.*BodyClose|ReplaceLast|"</body>"[[:space:]]*,' "$src" \
| grep -v 'FileTransformationRegistration.cs'; then
echo "FAIL (JR-021): index.html injection logic found outside the File Transformation callback." >&2
status=1
fi
# WebClientPatchService is removal-only. Any write there must be the cleaned
# document; a write of a *patched* one is the regression this guards.
if grep -n -E 'WriteAllText\((?!.*cleaned)' -P "$src/Services/WebClientPatchService.cs" >/dev/null 2>&1; then
echo "FAIL (JR-021): WebClientPatchService writes something other than the cleaned document." >&2
status=1
fi
# The disk-patching entry point must not come back.
if grep -rn --include='*.cs' -E '\bWebClientPatchService\.Apply\b' "$src"; then
echo "FAIL (JR-021): the injecting Apply() entry point has been reintroduced." >&2
status=1
fi
if [ "$status" -eq 0 ]; then
echo "OK (JR-021): no on-disk injection path."
fi
exit "$status"
+31
View File
@@ -0,0 +1,31 @@
# traceability.toml — per-repo configuration for the shared trace extractor.
# The extractor itself is vendored at scripts/vendor/jray-project.
# Flat, rather than split by theme as scene-actor-extraction is. The plugin is
# one deployable with one audience, and JRay-public-server already ships UR/DR
# — a second repo using those prefixes would make UR-007 ambiguous across
# registers, and UR-007 is precisely the ID the server asks this register to
# cross-reference (see JR-025).
requirement_types = ["JR"]
languages = ["csharp", "javascript"]
# The static checks carry TRACES tags of their own. An enforcement script is
# evidence for a requirement exactly as a unit test is — JR-021 is a
# prohibition, and a prohibition can only be verified by absence.
source_suffixes = [".sh"]
# Explicit roots rather than "scripts", which would walk scripts/vendor and
# harvest the AR-nnn examples in the extractor's own docstrings as orphan tags.
source_roots = [
"Jellyfin.Plugin.JRay",
"Jellyfin.Plugin.JRay.Tests",
"scripts/checks",
]
# jRay has two CI tiers and one live tier. T3 is deliberately unused: T4 keeps
# the meaning it has in scene-actor-extraction — "no CI host can run this" —
# so a tier number means the same thing when read across repos.
ci_executable_tiers = ["T1", "T2", "static"]
system_spec = "scripts/vendor/jray-project/SPEC.md"