14 Commits
Author SHA1 Message Date
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
49 changed files with 3202 additions and 125 deletions
@@ -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,56 @@
<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>
</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 = 1, Movie = "/m.mkv" };
var actor = new TruthActor { Name = "A", TmdbId = "884" };
actor.Scenes.Add([1.0, 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,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(new[] { 140.0, 160.0 }, truth.Actors[0].Scenes[0]);
Assert.Equal(new[] { 240.0, 260.0 }, truth.Actors[0].Scenes[1]);
}
[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][0]);
Assert.True(truth.Actors[0].Scenes[0][1] >= truth.Actors[0].Scenes[0][0]);
}
[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(new[] { 10.0, 20.0 }, truth.Actors[0].Scenes[0]);
Assert.Equal(new[] { 20.0, 30.0 }, truth.Actors[0].Scenes[1]);
}
[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,150 @@
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(w);
}
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":1,"movie":"/m.mkv","sample_fps":1,"anneal_sec":2,
"actors":[{"name":"A","imdb_id":"","tmdb_id":"884","jellyfin_id":"",
"scenes":[[0.0,10.0],[10.0,20.0],[30.0,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]);
Assert.Equal([10.0, 20.0], windows[1]);
Assert.Equal([30.0, 30.0], windows[2]);
}
// 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([w * 10.0, (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,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));
}
}
+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
@@ -23,6 +23,28 @@ public enum ServerTrustLevel
/// 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>
@@ -31,11 +53,8 @@ public enum MatchTier
/// <summary>Runtimes within ±2s.</summary>
Runtime = 1,
/// <summary>Audio signature score ≥ 0.85; may carry a non-zero offset.</summary>
/// <summary>Audio signature score ≥ 0.85; may carry a non-zero offset. The top tier.</summary>
Audio = 2,
/// <summary>Identical <c>video_hash</c> — the same file.</summary>
Exact = 3,
}
/// <summary>
@@ -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;
@@ -23,7 +23,7 @@ namespace Jellyfin.Plugin.JRay.Controllers;
[ApiController]
[Route("Plugins/JRay/Items/{itemId}")]
[Authorize]
// TRACES: JR-004, JR-005, JR-012, JR-013, JR-014 | SR-002
// TRACES: JR-004, JR-005, JR-010, JR-012, JR-013, JR-014 | SR-002
public class ActorsController : ControllerBase
{
private readonly ITruthDataService _truthDataService;
@@ -38,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>
@@ -58,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>
@@ -78,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,200 @@
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 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="logger">Logger.</param>
public ManifestController(
ILibraryManager libraryManager,
IManifestExchangeClient exchange,
IManagedTruthStore truthStore,
ILogger<ManifestController> logger)
{
_libraryManager = libraryManager;
_exchange = exchange;
_truthStore = truthStore;
_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" });
}
// 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, outcome.OffsetSec, item.Path ?? string.Empty);
var provenance = new TruthProvenance
{
Source = TruthSource.Fetched,
ServerUrl = outcome.ServerUrl,
MatchTier = outcome.Tier,
OffsetSec = outcome.OffsetSec,
Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec),
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)",
itemId,
outcome.ServerUrl,
outcome.Tier,
outcome.OffsetSec);
return Ok(new ManifestFetchResult
{
ServerUrl = outcome.ServerUrl,
Match = outcome.Tier.ToString().ToLowerInvariant(),
OffsetSec = outcome.OffsetSec,
ActorCount = truth.Actors.Count,
Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec),
});
}
/// <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 };
}
}
@@ -59,7 +59,8 @@ public class TruthController : ControllerBase
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}.");
}
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();
@@ -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.
+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,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();
}
+1 -1
View File
@@ -45,7 +45,7 @@ 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 list of [start_sec, end_sec] windows during which the actor is in the scene.
/// </summary>
[JsonPropertyName("scenes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+1 -1
View File
@@ -45,7 +45,7 @@ public class TruthFile
public double AnnealSec { 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)]
@@ -0,0 +1,98 @@
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 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 };
}
@@ -17,5 +17,10 @@ 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>();
}
}
@@ -23,9 +23,10 @@ namespace Jellyfin.Plugin.JRay.Services;
/// <c>AssemblyLoadContext</c> from the real one, which is precisely the failure
/// the reflection integration exists to avoid.
///
/// This is the mechanism JR-021 requires, but it does not by itself satisfy
/// JR-021 — that requirement is a prohibition, and it stays unmet while
/// <see cref="WebClientPatchService"/> can still write to disk.
/// 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
@@ -72,7 +73,12 @@ public static class FileTransformationRegistration
var registerMethod = ResolveRegisterMethod();
if (registerMethod is null)
{
logger.LogInformation("JRay: File Transformation plugin not found; falling back to patching index.html on disk.");
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;
}
@@ -84,7 +90,11 @@ public static class FileTransformationRegistration
}
catch (Exception ex) when (ex is TargetInvocationException or InvalidOperationException or JsonException or MissingMethodException)
{
logger.LogWarning(ex, "JRay: failed to register with the File Transformation plugin; falling back to patching index.html on disk.");
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;
}
}
@@ -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);
}
@@ -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.
@@ -60,7 +60,7 @@ public sealed class ManagedTruthStore : IManagedTruthStore
}
/// <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.");
@@ -73,11 +73,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))
{
@@ -98,4 +139,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,100 @@
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 = 1,
Movie = mediaPath ?? string.Empty,
SampleFps = manifest.Extraction?.SampleFps ?? 0,
};
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[] { start, end });
}
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>
/// <returns>A caveat string, or null when the match needs no explanation.</returns>
public static string? DescribeCaveat(MatchTier tier, double offsetSec)
{
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,447 @@
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>
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,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.Length == 2 && window[0] <= t && t <= window[1])
{
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.Length != 2)
{
continue;
}
if (window[0] < previousStart)
{
return false;
}
previousStart = window[0];
}
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);
}
}
@@ -93,6 +93,34 @@ public sealed class TruthDataService : ITruthDataService
}
}
/// <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)
{
+16
View File
@@ -218,6 +218,22 @@
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.className = 'jrayActorCard';
+18 -15
View File
@@ -1,11 +1,11 @@
# JRay
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 on screen at that exact moment.
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 visible at a given timestamp. A small overlay, injected into the Jellyfin web
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
@@ -47,7 +47,7 @@ patch on startup, so there is nothing to clean up by hand. See
## Features
- **Pause overlay** — pause a movie or episode in the web client and see the
actors currently on screen, without leaving the player.
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
@@ -82,12 +82,12 @@ patch on startup, so there is nothing to clean up by hand. See
```
1. The extraction pipeline analyses a film offline and emits a **truth file**
listing each detected actor and the time windows they're on screen.
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 on-screen actors as an overlay.
renders the scene's cast as an overlay.
## Truth File Format
@@ -112,8 +112,11 @@ configurable). Schema (`schema_version: 1`, minimal verbosity):
}
```
An actor is considered visible at timestamp `t` (seconds) if any of their
`scenes` windows satisfies `start <= t <= end`. JRay prefers `jellyfin_id` (a
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`.
@@ -127,7 +130,7 @@ 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 (on-screen actors), or `404`. |
| `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. |
@@ -148,12 +151,12 @@ the `X-Emby-Token: <token>` header or `Authorization: MediaBrowser Token="<token
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 on screen**.
**what is playing and where**, then ask JRay **who is in the scene**.
### 1. Query on-screen actors: `GET /Items/{itemId}/jray?t={seconds}`
### 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
actors visible at that timestamp. This is the only call most clients need.
the actors in that scene. This is the only call most clients need.
**Request**
@@ -177,7 +180,7 @@ X-Emby-Token: <user-or-api-token>
}
```
- `actors` may be an **empty array** when no one is on screen at `t` — that's a
- `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
@@ -193,7 +196,7 @@ X-Emby-Token: <user-or-api-token>
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's on screen" client-side (e.g. to drive a scrubber-bar heatmap)
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.
### Reference implementation (web client)
@@ -212,7 +215,7 @@ var s = sessions[0];
var itemId = s.NowPlayingItem.Id;
var t = (s.PlayState.PositionTicks || 0) / 10000000; // ticks → seconds
// 2. Ask JRay who is on screen. ApiClient adds the auth token for you.
// 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'
@@ -355,7 +358,7 @@ Jellyfin.Plugin.JRay/
`<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 on-screen actors.
calls `jray?t=`, and renders the scene's cast.
## Important Notes
+89 -29
View File
@@ -159,8 +159,12 @@ It stores and serves what it was given. The one permitted transformation is the
timebase offset of JR-030, which shifts every window uniformly and so preserves
the claim.
**Gap:** stated nowhere in the code today. The read path happens to comply, but
by not having been written to do otherwise rather than by requirement.
**Current:** satisfied.
[`PresenceLookup`](Jellyfin.Plugin.JRay/Services/PresenceLookup.cs) is now the
unit that decides presence, so the semantics live in one tagged place instead of
being implied by a LINQ predicate in the controller. UT-021 pins that adjacent
windows such as `[0,10]` and `[10,20]` are *not* merged, and UT-022 that a truth
file round-trips byte-identical. **Gap:** none.
### JR-005 — Query semantics, and how presence is presented
@@ -175,14 +179,17 @@ scene" is the claim it does.
This is a wording requirement, not a hedge — it is the difference between the
product being right and being a worse version of a frame-by-frame detector.
**Current:** the query is implemented correctly in
[`ActorsController`](Jellyfin.Plugin.JRay/Controllers/ActorsController.cs).
**Gap:** wording, not logic. The overlay renders a bare list with **no heading
at all**, so it asserts nothing — but it also tells the viewer nothing about
what the list means, and a viewer's default reading of a paused frame is "these
people are on screen". [`README.md`](README.md) states that reading outright
("which actors are on screen at that exact moment"), and the model type is
`ActorAtTime`.
**Current:** satisfied, in logic and in wording. Bounds are inclusive at both
ends (UT-016/017), a zero-length window is a real sighting rather than a
degenerate one to discard (UT-018), and overlapping windows resolve (UT-019).
The wording was the larger half. The overlay now carries an **"In this scene"**
heading — previously it rendered a bare list, which asserted nothing but also
told the viewer nothing, and a viewer's default reading of a paused frame is
"these people are on screen". The model type `ActorAtTime` became `ActorInScene`,
and [`README.md`](README.md) no longer contains the word "on screen" anywhere;
it stated the forbidden reading outright in seven places, including the opening
sentence. **Gap:** none.
### JR-006 — Numerous windows
@@ -195,8 +202,21 @@ The read path must therefore treat per-actor windows as a sorted sequence to be
searched, not a short list to be scanned, and the `jray?t=` response must stay
small regardless of how many windows an actor has.
**Gap:** windows are scanned linearly and the whole truth file is held per item.
Adequate at current sizes; unmeasured, and unstated until now.
**Current:** satisfied, and now measured rather than assumed. UT-023 builds 50
actors × 1000 windows and asserts the `jray?t=` result is bounded by **actor**
count, never window count — which is what keeps the response small however finely
presence is sliced.
**The lookup is a full scan, deliberately.** An early exit on `start > t` would
exploit the sortedness the format requires, but it would silently under-report
the moment one producer emitted windows out of order — a correctness risk traded
for a saving that does not register at this scale. UT-020 pins that unsorted
input still resolves. `WindowsAreSorted` exists as a diagnostic for surfacing
such a producer bug, not as something correctness depends on.
**Gap:** none for lookup. The whole truth file is still held in memory per cached
item, which is a memory question rather than a query-cost one and is untouched
here.
### JR-007 — Identity is public identifiers
@@ -245,9 +265,20 @@ and at what match tier. A `loose`-tier fetch from a third-party server and a
locally-computed sidecar are not the same claim, and JR-036 requires the
difference be surfaceable.
**Current:** two-way precedence implemented in
[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs).
**Gap:** no provenance is recorded.
**Current:** satisfied. Two-way precedence in
[`TruthDataService`](Jellyfin.Plugin.JRay/Services/TruthDataService.cs), and
[`TruthProvenance`](Jellyfin.Plugin.JRay/Models/TruthProvenance.cs) records
source, server, tier, applied offset and caveat. `GET .../Provenance` serves it.
Two decisions worth keeping. **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 this).
And **the applied offset is recorded** because it is otherwise unrecoverable:
once JR-030 shifts the windows they look native, and nothing would say they had
been shifted.
A sidecar's provenance is derived rather than stored — it is local, and its
timestamp is the file's own. **Gap:** none.
### JR-011 — Caching
@@ -477,13 +508,19 @@ Optionally, publish jRay through a repository manifest that also lists File
Transformation, so one repository URL surfaces both. This is not a dependency
mechanism; it removes a step and the chance of installing the wrong thing.
**Current:** all three implemented. Startup detection and registration, a warning
naming the plugin and its install URL, and a status banner on the configuration
page fed by `GET /Plugins/JRay/Status/Dependencies` — satisfied, or missing with
the manifest URL and what to do with it. The README now states the dependency
before the install step rather than after it. **Gap:** no test executes the
detection branch, so this stays `In Progress`; the config-page half is T4 and
verifiable only against a live server.
**Current:** all three implemented, and the detection half is tested
(UT-012…015). Startup detection and registration, a warning naming the plugin
and its install URL, and a status banner on the configuration page fed by
`GET /Plugins/JRay/Status/Dependencies`. The README states the dependency before
the install step rather than after it.
Both failure-path log messages previously said the overlay was "falling back to
patching index.html on disk" — a claim JR-021 made false, and the worst place to
leave one: an admin reading it while debugging a missing overlay would go hunting
for a patch that no longer exists. UT-013 asserts the message names the install
URL and does *not* claim a fallback.
**Gap:** the config-page banner is T4, verifiable only against a live server.
### JR-024 — Names render as text, never markup
@@ -619,14 +656,37 @@ a rejected upload would otherwise push tens of MiB pointlessly.
### JR-036 — Match tier is the user's dial
The configured minimum tier (`exact` / `audio` / `runtime` / `loose`) gates what
may be stored. A `loose` match — runtimes within ±30 s — is plausibly a different
trim of the same cut, so it is **surfaced as a caveat in the UI**, not applied
The configured minimum tier (`audio` / `runtime` / `loose`) gates what may be
stored. A `loose` match — runtimes within ±30 s — is plausibly a different trim
of the same cut, so it is **surfaced as a caveat in the UI**, not applied
silently. Per JR-010 the tier is recorded with the stored truth, which is what
makes surfacing it possible after the fetch has finished.
**Current:** `MinimumMatchTier` exists in configuration, defaulting to `runtime`.
**Gap:** nothing reads it; no caveat is displayed.
**There is no `exact` tier here, and the plugin sends no `video_hash`.** The
server spec §3 defines `exact` as an equal OpenSubtitles file hash, and it is the
strongest *technical* signal available — it identifies a specific file, so it
cannot produce a false positive. That is exactly why it is withdrawn.
A TMDB id discloses "some copy of this film", which is what a library catalogue
discloses. A file hash discloses **this exact release**, which turns a catalogue
lookup into a release-identification service and turns a 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 asked to hold.
The audio signature is the deliberate replacement: derived from *content*, it
identifies the **cut** rather than the copy, 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. `audio` is therefore the
top tier.
A server may still hold hashes contributed by other clients; this plugin simply
never participates, and `MatchTier` has no `Exact` member so no code path can
come to depend on one.
**Current:** `MinimumMatchTier` exists in configuration, defaulting to `runtime`,
and `ManifestExchangeClient` rejects a below-tier match. **Gap:** the caveat is
returned by the fetch endpoint but not yet displayed in the overlay.
---
@@ -690,8 +750,8 @@ test rather than an aspiration. Extraction's counterpart is `IR-005`.
**JR-044 — media shorter than 120 s.** The window underflows, so **no signature
is emitted and no sync offset is applied**. Such items fall back to the runtime
and exact tiers, which is adequate: a 90-second extra is not content whose cut
alignment matters. Both producers must apply the identical rule, or they diverge
tier, which is adequate: a 90-second extra is not content whose cut alignment
matters. Both producers must apply the identical rule, or they diverge
on exactly the short items most likely to be misidentified. Extraction's
counterpart is `IR-007`.
+135 -54
View File
@@ -16,6 +16,54 @@ Tag code with `// TRACES: JR-012 | SR-002`.
| `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** |
All execute and pass. 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. Three so far, each 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 |
The third 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.
@@ -35,9 +83,9 @@ coordinated `schema_version` bumps (SR-003).
| JR-001 | The truth-file format is normatively defined here; other repos reference it rather than restating it | SR-003 | High | In Progress |
| JR-002 | `schema_version: 2` shape — `extraction.*` provenance block, `cut.*` block, `scenes` as objects carrying belief and route | SR-003 | High | Planned |
| JR-003 | Reject an unknown `schema_version`, never guess. **Flag day: v2 only**, no dual-accept | SR-003 | High | Planned |
| JR-004 | A window is a **scene-membership claim**, not a recognition event — never reinterpreted, merged, split or trimmed | **SR-002** | High | Planned |
| JR-005 | Query semantics: actor present at `t` if any window contains `t`; presentation must not assert instantaneous visibility | **SR-002** | High | In Progress |
| JR-006 | Read path holds up under **numerous** windows — no assumption of a handful of long ones | SR-002 | Medium | Planned |
| JR-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)
@@ -46,7 +94,7 @@ coordinated `schema_version` bumps (SR-003).
|---|---|---|---|---|
| JR-008 | Discover a sidecar truth file beside the media, by configurable suffix | PR-001 | High | Done |
| JR-009 | Accept truth data pushed by a remote worker (`PUT`/`DELETE`), admin key | PR-004 | High | Done |
| JR-010 | Precedence: managed truth (pushed **or** fetched) overrides a sidecar; provenance is recorded so the UI can distinguish the three sources | PR-001 | High | In Progress |
| JR-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)
@@ -62,7 +110,7 @@ coordinated `schema_version` bumps (SR-003).
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
| JR-015 | `Tasks/Pending` serves a random sample of items with no truth data, so pollers spread across the backlog without server-side task state | PR-003 | High | Done |
| JR-016 | Prioritise/ignore rules scoped `Genre` / `Series` / `Item`; **most specific wins**; scope+value is the unique key | PR-003 | Medium | Done |
| JR-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 |
@@ -73,8 +121,8 @@ coordinated `schema_version` bumps (SR-003).
|---|---|---|---|---|
| JR-020 | Pause overlay: injected client script queries `jray?t=` and renders the scene's cast | **PR-001** | High | Done |
| JR-021 | **jRay never injects into `index.html` on disk.** File Transformation is a hard dependency; there is no on-disk fallback. The only permitted write is JR-022's removal | PR-004 | High | **Done** |
| JR-022 | Migration: remove any on-disk patch left by an earlier jRay, identified by the `<!-- jray-overlay -->` marker | PR-004 | High | In Progress |
| JR-023 | Absent the dependency, disable **only** the overlay and say so in the log and the config page; never bundle the assembly | PR-004 | Medium | In Progress |
| JR-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)
@@ -92,19 +140,19 @@ stay `In Progress` until `JR-025` is `Done`.
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
| JR-025 | Query an **ordered list** of servers; first result clearing the configured tier wins — **satisfies `JRay-public-server` UR-007** | PR-006 | High | In Progress |
| JR-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 | Planned |
| JR-028 | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB bundle — aborting rather than buffering | SR-004 | High | Planned |
| JR-029 | HTTPS required for non-loopback servers; certificate validation must not be disabled | SR-004 | High | Planned |
| JR-030 | Apply an `audio`-tier `offset` to **every** window before storing — stored truth is always in the local file's timebase, so read paths need no offset awareness | SR-003 | High | Planned |
| JR-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 | Planned |
| 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)
@@ -144,6 +192,15 @@ 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
@@ -159,17 +216,47 @@ every requirement above except the live-integration ones is executable in CI.
| **T4 — Live** | **No** | Real Jellyfin + File Transformation + web client; real manifest server round-trip |
| **static** | Yes | Grep/analyzer checks — e.g. no injection path into `index.html` (JR-021) |
**T3 is deliberately unused.** The gate's `CI_EXECUTABLE_TIERS` treats T1/T2/T3
as CI-runnable and T4 as not, which is right for extraction (where T3 is slow CPU
inference and T4 is GPU). jRay has only two CI tiers and one live tier, so its
non-CI tier is numbered **T4** to match that shared constant rather than
renumbering it. Calling jRay's live tier "T3" would make the gate count
live-only requirements as covered — the exact class of error the 158% coverage
bug belongs to.
**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.
**There is no test project today.** That is the single largest gap in this
register: 46 requirements, zero `UT`/`IT` IDs, so measured coverage will open at
zero and every `Done` above rests on inspection rather than evidence.
`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
@@ -180,11 +267,11 @@ zero and every `Done` above rests on inspection rather than evidence.
| JR-003 | **T1** | `schema_version` 1 and 3 are both **rejected**, not coerced | Missing field entirely; non-integer value |
| JR-004 | T1 | Windows are stored and served byte-identical to input | Adjacent windows that "look" mergeable must **not** merge |
| JR-005 | T1 | `t` exactly on `start` and on `end` are both present | Zero-length window; overlapping windows for one actor |
| JR-006 | T1 | Query cost is acceptable with 10³ windows on one actor | Sorted-window assumption stated and tested |
| JR-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 | Fetched and pushed truth for the same item |
| 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` |
@@ -197,7 +284,7 @@ zero and every `Done` above rests on inspection rather than evidence.
| JR-018 | T1 | `covered / (total - ignored)` | Item carrying two genres counts in both rows |
| JR-021 | **static** | No code path *adds* the script tag to `index.html` | `scripts/checks/no-index-injection.sh`. Removal (JR-022) is the one permitted write, so the check is on injection, not on writing. Verified to **fail** on a reintroduced `Apply()` and on reintroduced `ReplaceLast` injection, not merely to pass today |
| JR-022 | T1 | A marked legacy patch is removed; unmarked content untouched | Foreign plugin's injection left intact |
| JR-023 | T1 + **T4** | Absent dependency disables only the overlay | Detection unit-testable; config-page display is live |
| JR-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 |
@@ -210,7 +297,7 @@ zero and every `Done` above rests on inspection rather than evidence.
| JR-033 | T1 | Sweep batches through `exists` and paces | Backlog smaller than one batch |
| JR-034 | **T1** | `movie` and `jellyfin_id` absent from the upload body | Contribution attempted to a `FetchOnly` server must not send |
| JR-035 | T1 | `Expect: 100-continue` set on uploads | — |
| JR-036 | T1 | Below-tier match is not stored; `loose` is flagged | Tier configured to `exact` with only a `runtime` match available |
| JR-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 |
@@ -220,7 +307,7 @@ zero and every `Done` above rests on inspection rather than evidence.
| JR-043 | **T1** | Signature matches the shared golden vector **bit-for-bit** | Media < 120 s → no signature; identical result in both repos |
| JR-044 | T1 | Media < 120 s yields no signature and no offset | Exactly 120 s — the boundary both repos must agree on |
| JR-045 | T1 | `v1:` emitted; an unknown prefix is refused, not parsed | `v2:` signature from a future producer |
| JR-046 | **TBD** | — | Undesigned; depends on AR-021/AR-022 and system open question 2 |
| 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
@@ -232,38 +319,32 @@ which is exactly why it can be the binding check rather than an aspiration.
## Running the gate
The shared extractor now takes the three things that vary per repo as arguments,
so this repo needs **no fork of it** — there must only ever be one
implementation:
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 . \
--requirements docs/requirements.md \
--system-spec scripts/vendor/jray-project/SPEC.md \
--types JR \
--suffixes .cs,.js,.sh \
--scan-roots Jellyfin.Plugin.JRay,scripts/checks \
--format coverage
--root . --format coverage
```
`scripts/checks` is scanned so the static checks carry their own TRACES tags —
an enforcement script is evidence for a requirement exactly as a unit test is.
The scan root is `scripts/checks` and **not** `scripts`, because the latter would
walk `scripts/vendor/jray-project` and harvest the `AR-nnn` examples in the
extractor's own docstrings as orphan tags.
`--root` must be **absolute or `.`**; the scan roots resolve beneath it. Both the
extractor and the system spec come from the submodule, so the only thing this
repo supplies is its own register and the three per-repo arguments.
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`.
**Naming conflict to resolve.** The tool's header comment expects
`jRay → UR/DR`. This register uses `JR`, decided deliberately: `JRay-public-server`
already ships `UR-001…018` and `DR-001…014`, so a second repo using the same
prefixes would make `UR-007` ambiguous across registers — and `UR-007` is
precisely the ID the server's own register asks the plugin to cross-reference
(see JR-025). Either the comment or this register is wrong; the comment is the
cheaper of the two to change.
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).
+8
View File
@@ -7,6 +7,14 @@
"owner": "dtourolle",
"category": "General",
"versions": [
{
"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": "52faba139f29c6a5b0a418845328071a",
"timestamp": "2026-07-31T08:05:39Z"
},
{
"version": "0.0.4",
"changelog": "Release 0.0.4",
+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"