feat(truth): schema_version 2 read path, v2 only

Replaces the v1 shape rather than accepting both. `anneal_sec` and the
top-level `sample_fps` are deleted, not zeroed; `extraction` and `cut`
blocks arrive; `scenes` become objects carrying belief and route, so a
window records how far to trust it instead of being a bare float pair.

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

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

TRACES: JR-002, JR-003 | SR-003
This commit is contained in:
2026-07-31 16:23:58 +02:00
parent 596566813c
commit 19aecee646
15 changed files with 583 additions and 46 deletions
@@ -43,9 +43,9 @@ public class ManagedTruthStoreTests : IDisposable
private static TruthFile Truth()
{
var truth = new TruthFile { SchemaVersion = 1, Movie = "/m.mkv" };
var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion, Movie = "/m.mkv" };
var actor = new TruthActor { Name = "A", TmdbId = "884" };
actor.Scenes.Add([1.0, 2.0]);
actor.Scenes.Add(new TruthScene { Start = 1.0, End = 2.0 });
truth.Actors.Add(actor);
return truth;
}
@@ -270,8 +270,8 @@ public class ManifestExchangeTests
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]);
Assert.Equal((140.0, 160.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
Assert.Equal((240.0, 260.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
}
[Fact]
@@ -284,8 +284,8 @@ public class ManifestExchangeTests
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]);
Assert.Equal(0.0, truth.Actors[0].Scenes[0].Start);
Assert.True(truth.Actors[0].Scenes[0].End >= truth.Actors[0].Scenes[0].Start);
}
[Fact]
@@ -302,8 +302,8 @@ public class ManifestExchangeTests
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]);
Assert.Equal((10.0, 20.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
Assert.Equal((20.0, 30.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
}
[Fact]
@@ -25,7 +25,7 @@ public class PresenceLookupTests
var actor = new TruthActor { Name = "Steve Buscemi", TmdbId = "884" };
foreach (var w in windows)
{
actor.Scenes.Add(w);
actor.Scenes.Add(new TruthScene { Start = w[0], End = w[1] });
}
return actor;
@@ -102,18 +102,22 @@ public class PresenceLookupTests
// 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,
{"schema_version":2,"movie":"/m.mkv",
"extraction":{"sample_fps":1,"extinction_sec":12},
"cut":{"runtime_sec":6420.5},
"actors":[{"name":"A","imdb_id":"","tmdb_id":"884","jellyfin_id":"",
"scenes":[[0.0,10.0],[10.0,20.0],[30.0,30.0]]}]}
"scenes":[{"start":0.0,"end":10.0},
{"start":10.0,"end":20.0},
{"start":30.0,"end":30.0}]}]}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(Json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
var windows = parsed.Actors[0].Scenes;
Assert.Equal(3, windows.Count);
Assert.Equal([0.0, 10.0], windows[0]);
Assert.Equal([10.0, 20.0], windows[1]);
Assert.Equal([30.0, 30.0], windows[2]);
Assert.Equal((0.0, 10.0), (windows[0].Start, windows[0].End));
Assert.Equal((10.0, 20.0), (windows[1].Start, windows[1].End));
Assert.Equal((30.0, 30.0), (windows[2].Start, windows[2].End));
}
// UT-023
@@ -129,7 +133,7 @@ public class PresenceLookupTests
var actor = Actor();
for (var w = 0; w < 1000; w++)
{
actor.Scenes.Add([w * 10.0, (w * 10.0) + 4.0]);
actor.Scenes.Add(new TruthScene { Start = w * 10.0, End = (w * 10.0) + 4.0 });
}
truth.Actors.Add(actor);
@@ -0,0 +1,229 @@
using System.Text.Json;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
/// <summary>
/// JR-002 (the <c>schema_version</c> 2 shape) and JR-003 (an unknown version is
/// refused, never guessed).
///
/// These are the flag-day tests. v1 is gone rather than deprecated, so what has
/// to be pinned is not only that v2 parses but that v1 does *not* quietly
/// half-parse into something a reader would treat as real.
///
/// TRACES: UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037 | JR-002, JR-003
/// </summary>
public class TruthSchemaTests
{
private static readonly JsonSerializerOptions Options = new(JsonSerializerDefaults.Web);
private const string V2 = """
{"schema_version":2,"movie":"/data/movies/Movie.mkv",
"extraction":{"sample_fps":5,"extinction_sec":12,"gallery_size":1820,
"gallery_scope":"global","pipeline_version":"scene-actor-extraction 0.4.1"},
"cut":{"runtime_sec":6420.5,"audio_signature":"v1:v7fA3k"},
"actors":[{"name":"Steve Buscemi","imdb_id":"nm0000114","tmdb_id":"884",
"jellyfin_id":"abc123-guid",
"scenes":[{"start":191.6,"end":209.2,"belief":0.98,"route":"live"},
{"start":438.2,"end":465.6,"belief":0.81,"route":"deferred"}]}]}
""";
// UT-029
[Fact]
public void AV2FileRoundTripsWithProvenanceAndCutIntact()
{
var parsed = JsonSerializer.Deserialize<TruthFile>(V2, Options)!;
Assert.Equal(2, parsed.SchemaVersion);
Assert.Equal("/data/movies/Movie.mkv", parsed.Movie);
// sample_fps moved into `extraction` in the bump. Reading it from the
// top level would silently yield zero.
Assert.Equal(5, parsed.Extraction!.SampleFps);
Assert.Equal(12, parsed.Extraction.ExtinctionSec);
Assert.Equal(1820, parsed.Extraction.GallerySize);
Assert.Equal("global", parsed.Extraction.GalleryScope);
Assert.Equal(6420.5, parsed.Cut!.RuntimeSec);
Assert.Equal("v1:v7fA3k", parsed.Cut.AudioSignature);
}
// UT-030
[Fact]
public void ScenesAreObjectsThatRetainBeliefAndRoute()
{
// The reason `scenes` stopped being float pairs. A window that loses its
// belief and route is indistinguishable from a v1 window, which is
// exactly the regression this pins.
var parsed = JsonSerializer.Deserialize<TruthFile>(V2, Options)!;
var windows = parsed.Actors[0].Scenes;
Assert.Equal(2, windows.Count);
Assert.Equal(0.98, windows[0].Belief);
Assert.Equal("live", windows[0].Route);
Assert.Equal(0.81, windows[1].Belief);
Assert.Equal("deferred", windows[1].Route);
}
// UT-031
[Theory]
[InlineData("live")]
[InlineData("deferred")]
[InlineData("pooled")]
public void AllThreeRoutesSurviveARoundTrip(string route)
{
// All three are named by extraction AR-017. A serializer that dropped an
// unrecognised one would make `pooled` claims look live.
var truth = new TruthFile { SchemaVersion = TruthSchema.SupportedVersion };
var actor = new TruthActor { Name = "A", TmdbId = "884" };
actor.Scenes.Add(new TruthScene { Start = 1, End = 2, Belief = 0.5, Route = route });
truth.Actors.Add(actor);
var round = JsonSerializer.Deserialize<TruthFile>(JsonSerializer.Serialize(truth, Options), Options)!;
Assert.Equal(route, round.Actors[0].Scenes[0].Route);
}
// UT-032
[Fact]
public void AWindowWithoutBeliefIsNullRatherThanZero()
{
// Absent and "believed with probability zero" are different statements.
// Defaulting to 0.0 would make an unannotated window look maximally
// untrustworthy, and a consumer ranking on belief would discard it.
const string NoBelief = """
{"schema_version":2,"movie":"/m.mkv",
"actors":[{"name":"A","tmdb_id":"884","scenes":[{"start":1,"end":2}]}]}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(NoBelief, Options)!;
Assert.Null(parsed.Actors[0].Scenes[0].Belief);
Assert.Null(parsed.Actors[0].Scenes[0].Route);
}
// UT-033 — JR-003, the flag day itself.
[Theory]
[InlineData(1)]
[InlineData(3)]
[InlineData(0)]
public void AnUnsupportedVersionIsRefused(int version)
{
// Both directions matter. v1 is the version that exists in the wild, and
// v3 is a future producer this build cannot know the shape of — guessing
// at either is what JR-003 forbids.
var truth = new TruthFile { SchemaVersion = version };
Assert.False(TruthSchema.IsSupported(truth));
Assert.Contains(version.ToString(System.Globalization.CultureInfo.InvariantCulture), TruthSchema.DescribeRejection(version), System.StringComparison.Ordinal);
}
// UT-034
[Fact]
public void AMissingSchemaVersionIsRefusedRatherThanAssumedCurrent()
{
// An absent field deserialises to 0. Treating that as "probably the
// current version" is the single most tempting mistake here, and it
// would accept any malformed document that happened to parse.
const string NoVersion = """
{"movie":"/m.mkv","actors":[]}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(NoVersion, Options);
Assert.False(TruthSchema.IsSupported(parsed));
}
// UT-035
[Fact]
public void ANullTruthFileIsRefusedWithoutThrowing()
{
// `null` reaches this from a file containing the literal `null`, which
// parses successfully. The check must reject it rather than dereference.
Assert.False(TruthSchema.IsSupported(null));
}
// UT-037
[Fact]
public void TheProducersActualOutputParses()
{
// Byte-for-byte the shape `scene-actor-extraction` writes today
// (`src/nodes/result_sink_node.hpp`, IR-002) — *not* the fully populated
// example from the spec. It omits `cut` entirely and carries only three
// of the five `extraction` fields.
//
// This is the test that would have caught the break: the plugin read v1
// while the pipeline had already moved to v2, so nothing the pipeline
// produced could be read at all. A round-trip test written against the
// spec's example alone would have passed throughout.
const string AsProduced = """
{
"schema_version": 2,
"movie": "/data/movies/Film.mkv",
"extraction": {
"sample_fps": 5.0,
"extinction_sec": 12.0,
"gallery_scope": "global"
},
"actors": [
{
"name": "Steve Buscemi",
"imdb_id": "nm0000114",
"tmdb_id": "884",
"jellyfin_id": "",
"scenes": [
{ "start": 191.6, "end": 209.2, "belief": 0.98, "route": "live" }
]
}
]
}
""";
var parsed = JsonSerializer.Deserialize<TruthFile>(AsProduced, Options);
Assert.True(TruthSchema.IsSupported(parsed));
Assert.Equal(5.0, parsed!.Extraction!.SampleFps);
Assert.Equal(12.0, parsed.Extraction.ExtinctionSec);
Assert.Equal("global", parsed.Extraction.GalleryScope);
// Absent blocks and fields are null, not defaults that would read as data.
Assert.Null(parsed.Cut);
Assert.Null(parsed.Extraction.GallerySize);
Assert.Null(parsed.Extraction.PipelineVersion);
var window = Assert.Single(parsed.Actors[0].Scenes);
Assert.Equal((191.6, 209.2), (window.Start, window.End));
Assert.Equal(0.98, window.Belief);
Assert.Equal("live", window.Route);
}
// UT-036
[Fact]
public void AV1FileDoesNotHalfParseIntoUsableWindows()
{
// The load-bearing claim of the flag day. v1 `scenes` were float pairs,
// so a v1 file either fails to deserialise or produces windows that are
// not usable — what must never happen is silent success with windows at
// 0,0, which would report actors present at the start of every film.
const string V1 = """
{"schema_version":1,"movie":"/m.mkv","sample_fps":1,"anneal_sec":2,
"actors":[{"name":"A","tmdb_id":"884","scenes":[[0.0,10.0],[10.0,20.0]]}]}
""";
TruthFile? parsed = null;
try
{
parsed = JsonSerializer.Deserialize<TruthFile>(V1, Options);
}
catch (JsonException)
{
// The expected path: a float pair is not an object.
return;
}
// If it did parse, the version gate is what stops it being used.
Assert.False(TruthSchema.IsSupported(parsed));
}
}