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() 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" }; 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); truth.Actors.Add(actor);
return truth; return truth;
} }
@@ -270,8 +270,8 @@ public class ManifestExchangeTests
var truth = ManifestConverter.ToTruthFile(m, 40, "/media/film.mkv"); var truth = ManifestConverter.ToTruthFile(m, 40, "/media/film.mkv");
Assert.Equal(new[] { 140.0, 160.0 }, truth.Actors[0].Scenes[0]); Assert.Equal((140.0, 160.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
Assert.Equal(new[] { 240.0, 260.0 }, truth.Actors[0].Scenes[1]); Assert.Equal((240.0, 260.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
} }
[Fact] [Fact]
@@ -284,8 +284,8 @@ public class ManifestExchangeTests
var truth = ManifestConverter.ToTruthFile(m, -40, "/media/film.mkv"); var truth = ManifestConverter.ToTruthFile(m, -40, "/media/film.mkv");
Assert.Equal(0.0, truth.Actors[0].Scenes[0][0]); Assert.Equal(0.0, truth.Actors[0].Scenes[0].Start);
Assert.True(truth.Actors[0].Scenes[0][1] >= truth.Actors[0].Scenes[0][0]); Assert.True(truth.Actors[0].Scenes[0].End >= truth.Actors[0].Scenes[0].Start);
} }
[Fact] [Fact]
@@ -302,8 +302,8 @@ public class ManifestExchangeTests
var truth = ManifestConverter.ToTruthFile(m, 0, "/media/film.mkv"); var truth = ManifestConverter.ToTruthFile(m, 0, "/media/film.mkv");
Assert.Equal(2, truth.Actors[0].Scenes.Count); Assert.Equal(2, truth.Actors[0].Scenes.Count);
Assert.Equal(new[] { 10.0, 20.0 }, truth.Actors[0].Scenes[0]); Assert.Equal((10.0, 20.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End));
Assert.Equal(new[] { 20.0, 30.0 }, truth.Actors[0].Scenes[1]); Assert.Equal((20.0, 30.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End));
} }
[Fact] [Fact]
@@ -25,7 +25,7 @@ public class PresenceLookupTests
var actor = new TruthActor { Name = "Steve Buscemi", TmdbId = "884" }; var actor = new TruthActor { Name = "Steve Buscemi", TmdbId = "884" };
foreach (var w in windows) foreach (var w in windows)
{ {
actor.Scenes.Add(w); actor.Scenes.Add(new TruthScene { Start = w[0], End = w[1] });
} }
return actor; return actor;
@@ -102,18 +102,22 @@ public class PresenceLookupTests
// JR-004: served exactly as given. A round trip through the serializer // JR-004: served exactly as given. A round trip through the serializer
// is where a silent normalisation would show up. // is where a silent normalisation would show up.
const string Json = """ 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":"", "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 parsed = JsonSerializer.Deserialize<TruthFile>(Json, new JsonSerializerOptions(JsonSerializerDefaults.Web))!;
var windows = parsed.Actors[0].Scenes; var windows = parsed.Actors[0].Scenes;
Assert.Equal(3, windows.Count); Assert.Equal(3, windows.Count);
Assert.Equal([0.0, 10.0], windows[0]); Assert.Equal((0.0, 10.0), (windows[0].Start, windows[0].End));
Assert.Equal([10.0, 20.0], windows[1]); Assert.Equal((10.0, 20.0), (windows[1].Start, windows[1].End));
Assert.Equal([30.0, 30.0], windows[2]); Assert.Equal((30.0, 30.0), (windows[2].Start, windows[2].End));
} }
// UT-023 // UT-023
@@ -129,7 +133,7 @@ public class PresenceLookupTests
var actor = Actor(); var actor = Actor();
for (var w = 0; w < 1000; w++) 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); 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));
}
}
@@ -2,6 +2,7 @@ using System;
using System.Threading; using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Models; using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces; using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@@ -15,10 +16,11 @@ namespace Jellyfin.Plugin.JRay.Controllers;
/// locally. See SPEC.md §2. /// locally. See SPEC.md §2.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// The <c>schema_version</c> check here refuses an unrecognised version rather /// The <c>schema_version</c> check refuses an unrecognised version rather than
/// than guessing at its shape. It is currently the only source that checks — /// guessing at its shape. It defers to <see cref="TruthSchema"/> rather than
/// sidecar reads do not — which JR-003 requires be fixed by moving the check /// holding its own constant: this used to be the only source that checked while
/// into the shared read path. /// sidecar reads did not, so the version the plugin claimed to require and the
/// one it would actually parse could drift apart.
/// </remarks> /// </remarks>
[ApiController] [ApiController]
[Route("Plugins/JRay/Items/{itemId}/Truth")] [Route("Plugins/JRay/Items/{itemId}/Truth")]
@@ -26,8 +28,6 @@ namespace Jellyfin.Plugin.JRay.Controllers;
// TRACES: JR-003, JR-009, JR-014 | SR-003 // TRACES: JR-003, JR-009, JR-014 | SR-003
public class TruthController : ControllerBase public class TruthController : ControllerBase
{ {
private const int SupportedSchemaVersion = 1;
private readonly IManagedTruthStore _managedTruthStore; private readonly IManagedTruthStore _managedTruthStore;
private readonly ITruthDataService _truthDataService; private readonly ITruthDataService _truthDataService;
@@ -54,9 +54,9 @@ public class TruthController : ControllerBase
[ProducesResponseType(StatusCodes.Status400BadRequest)] [ProducesResponseType(StatusCodes.Status400BadRequest)]
public async Task<IActionResult> PutTruth(Guid itemId, [FromBody] TruthFile truth, CancellationToken cancellationToken) public async Task<IActionResult> PutTruth(Guid itemId, [FromBody] TruthFile truth, CancellationToken cancellationToken)
{ {
if (truth.SchemaVersion != SupportedSchemaVersion) if (!TruthSchema.IsSupported(truth))
{ {
return BadRequest($"Unsupported schema_version {truth.SchemaVersion}; expected {SupportedSchemaVersion}."); return BadRequest(TruthSchema.DescribeRejection(truth.SchemaVersion));
} }
var provenance = TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow); var provenance = TruthProvenance.Local(TruthSource.Pushed, DateTime.UtcNow);
+6 -2
View File
@@ -45,9 +45,13 @@ public class TruthActor
public string JellyfinId { get; set; } = string.Empty; public string JellyfinId { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets the list of [start_sec, end_sec] windows during which the actor is in the scene. /// Gets the windows during which the actor is in the scene.
/// </summary> /// </summary>
/// <remarks>
/// Objects since <c>schema_version</c> 2, not the float pairs v1 used, so a
/// window can carry the belief and route behind the claim.
/// </remarks>
[JsonPropertyName("scenes")] [JsonPropertyName("scenes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<double[]> Scenes { get; } = new(); public Collection<TruthScene> Scenes { get; } = new();
} }
+35
View File
@@ -0,0 +1,35 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Which encode the timings in a <see cref="TruthFile"/> apply to (SPEC.md §1,
/// JR-002).
/// </summary>
/// <remarks>
/// Timings are only meaningful against a particular cut. Recording the runtime
/// they were measured from is what lets a consumer notice that a file has been
/// re-encoded, re-trimmed, or replaced with a different release — rather than
/// silently showing an actor twenty seconds late.
/// </remarks>
// TRACES: JR-002 | SR-003
public class TruthCut
{
/// <summary>
/// Gets or sets the decoded duration the timings came from, in seconds.
/// </summary>
[JsonPropertyName("runtime_sec")]
public double? RuntimeSec { get; set; }
/// <summary>
/// Gets or sets the version-prefixed spectral-peak audio signature, or
/// <c>null</c> when the producer emitted none.
/// </summary>
/// <remarks>
/// Carries its own <c>v1:</c> prefix so a DSP change is detectable rather
/// than silently non-matching (JR-045). Media shorter than 120 s carries no
/// signature at all (JR-044).
/// </remarks>
[JsonPropertyName("audio_signature")]
public string? AudioSignature { get; set; }
}
@@ -0,0 +1,53 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Extraction provenance for a <see cref="TruthFile"/> (SPEC.md §1, JR-002).
/// </summary>
/// <remarks>
/// These fields moved here from the top level in the SR-003 bump so that the
/// truth file and the Jmanifest's <c>extraction</c> block have the same shape.
/// They differed for no reason, and two nearly-identical shapes are what makes a
/// converter quietly drop a field.
/// <para>
/// <b>There is no <c>anneal_sec</c>.</b> It was withdrawn rather than retained
/// as a vestigial zero: presence now follows track extent, so a track survives
/// its own gaps and there is nothing to anneal (extraction AR-012/AR-013). A
/// field naming a mechanism the pipeline no longer has is actively misleading,
/// and would outlive everyone who remembers why it reads zero.
/// </para>
/// </remarks>
// TRACES: JR-002 | SR-003
public class TruthExtraction
{
/// <summary>Gets or sets the sampling rate used during extraction.</summary>
[JsonPropertyName("sample_fps")]
public double? SampleFps { get; set; }
/// <summary>
/// Gets or sets the re-acquisition timeout that shapes window extent, in
/// seconds. Successor to the withdrawn <c>anneal_sec</c>.
/// </summary>
/// <remarks>
/// This is what a consumer needs in order to interpret a window: it bounds
/// how long an actor could be unseen without the window being closed.
/// </remarks>
[JsonPropertyName("extinction_sec")]
public double? ExtinctionSec { get; set; }
/// <summary>Gets or sets the producing pipeline's version string.</summary>
[JsonPropertyName("pipeline_version")]
public string? PipelineVersion { get; set; }
/// <summary>Gets or sets how many references the gallery held.</summary>
[JsonPropertyName("gallery_size")]
public int? GallerySize { get; set; }
/// <summary>
/// Gets or sets <c>global</c> or <c>limited</c> — the strongest single
/// quality signal when two manifests compete for one cut.
/// </summary>
[JsonPropertyName("gallery_scope")]
public string? GalleryScope { get; set; }
}
+24 -11
View File
@@ -5,7 +5,7 @@ namespace Jellyfin.Plugin.JRay.Models;
/// <summary> /// <summary>
/// Root object of a scene-actor-extraction "truth" file /// Root object of a scene-actor-extraction "truth" file
/// (schema_version 1, minimal verbosity). See SPEC.md §1. /// (<c>schema_version</c> 2). See SPEC.md §1.
/// </summary> /// </summary>
/// <remarks> /// <remarks>
/// JRay <b>owns</b> this format; the extraction pipeline is its producer and the /// JRay <b>owns</b> this format; the extraction pipeline is its producer and the
@@ -13,15 +13,22 @@ namespace Jellyfin.Plugin.JRay.Models;
/// independently, breaking changes are batched into one coordinated /// independently, breaking changes are batched into one coordinated
/// <c>schema_version</c> bump rather than made piecemeal. /// <c>schema_version</c> bump rather than made piecemeal.
/// ///
/// This type is still the v1 shape. JR-002 replaces it: <c>anneal_sec</c> out, /// <para>
/// an <c>extraction</c> provenance block and a <c>cut</c> block in, and /// <b>This is the v2 shape, and v1 is gone rather than deprecated.</b> The bump
/// <c>scenes</c> becoming objects that carry belief and identification route. /// removed <c>anneal_sec</c>, moved <c>sample_fps</c> into
/// <see cref="TruthExtraction"/>, added <see cref="TruthCut"/>, and turned
/// <c>scenes</c> from float pairs into <see cref="TruthScene"/> objects carrying
/// belief and route. Nothing here reads a v1 file: see
/// <see cref="Services.TruthSchema"/> for why that is a decision rather than an
/// omission.
/// </para>
/// </remarks> /// </remarks>
// TRACES: JR-001, JR-002 | SR-003 // TRACES: JR-001, JR-002 | SR-003
public class TruthFile public class TruthFile
{ {
/// <summary> /// <summary>
/// Gets or sets the schema version of this file. /// Gets or sets the schema version of this file. Only
/// <see cref="Services.TruthSchema.SupportedVersion"/> is accepted.
/// </summary> /// </summary>
[JsonPropertyName("schema_version")] [JsonPropertyName("schema_version")]
public int SchemaVersion { get; set; } public int SchemaVersion { get; set; }
@@ -29,20 +36,26 @@ public class TruthFile
/// <summary> /// <summary>
/// Gets or sets the source media path at extraction time (informational). /// Gets or sets the source media path at extraction time (informational).
/// </summary> /// </summary>
/// <remarks>
/// Stripped on contribution (JR-034): it is a contributor's directory
/// layout, which is nobody else's business and identifies them.
/// </remarks>
[JsonPropertyName("movie")] [JsonPropertyName("movie")]
public string Movie { get; set; } = string.Empty; public string Movie { get; set; } = string.Empty;
/// <summary> /// <summary>
/// Gets or sets the sampling rate (frames per second) used during extraction. /// Gets or sets extraction provenance, or <c>null</c> when the producer
/// recorded none.
/// </summary> /// </summary>
[JsonPropertyName("sample_fps")] [JsonPropertyName("extraction")]
public double SampleFps { get; set; } public TruthExtraction? Extraction { get; set; }
/// <summary> /// <summary>
/// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene. /// Gets or sets which encode the timings apply to, or <c>null</c> when the
/// producer recorded none.
/// </summary> /// </summary>
[JsonPropertyName("anneal_sec")] [JsonPropertyName("cut")]
public double AnnealSec { get; set; } public TruthCut? Cut { get; set; }
/// <summary> /// <summary>
/// Gets the list of actors in the film, each with their scene-presence windows. /// Gets the list of actors in the film, each with their scene-presence windows.
+50
View File
@@ -0,0 +1,50 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One presence window in a <see cref="TruthFile"/>.
/// </summary>
/// <remarks>
/// <b>A window is a claim about scene membership, not a recognition event</b>
/// (SR-002). An actor who turns away, is occluded, or is off-camera while the
/// shot cuts to whoever they are speaking to is still present — so a consumer
/// must never read a boundary as "the face was detected here", and must not
/// merge, split, trim or reorder windows.
/// <para>
/// In <c>schema_version</c> 1 this was a bare <c>[start, end]</c> float pair. It
/// became an object in the SR-003 bump so a window can carry the evidence behind
/// it: a consumer that shows presence should be able to say how strongly it is
/// believed and how it was arrived at, which a pair of numbers cannot express.
/// </para>
/// </remarks>
// TRACES: JR-002, JR-004 | SR-002, SR-003
public class TruthScene
{
/// <summary>Gets or sets the window start, in seconds, inclusive.</summary>
[JsonPropertyName("start")]
public double Start { get; set; }
/// <summary>Gets or sets the window end, in seconds, inclusive.</summary>
[JsonPropertyName("end")]
public double End { get; set; }
/// <summary>
/// Gets or sets the accumulated posterior that justified this claim, in
/// <c>[0, 1]</c>, or <c>null</c> when the producer did not record one.
/// </summary>
/// <remarks>
/// Optional rather than defaulted to zero: absent and "believed with
/// probability zero" are different statements, and a claim nobody believes
/// would not have been written.
/// </remarks>
[JsonPropertyName("belief")]
public double? Belief { get; set; }
/// <summary>
/// Gets or sets how the actor was identified: <c>live</c>, <c>deferred</c>
/// or <c>pooled</c> (extraction AR-017).
/// </summary>
[JsonPropertyName("route")]
public string? Route { get; set; }
}
@@ -49,12 +49,30 @@ public sealed class ManagedTruthStore : IManagedTruthStore
try try
{ {
using var stream = File.OpenRead(path); using var stream = File.OpenRead(path);
return await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken) var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
// Managed truth is checked on the way out as well as on the way in.
// Data written by an earlier plugin version is already on disk, and
// it did not pass today's PUT.
if (!TruthSchema.IsSupported(truth))
{
_logger.LogWarning(
"JRay: ignoring managed truth {Path} — schema_version {Found}, expected {Expected}. Re-push or re-extract this item.",
path,
truth?.SchemaVersion ?? 0,
TruthSchema.SupportedVersion);
return null;
}
return truth;
} }
catch (Exception ex) when (ex is IOException or JsonException) catch (Exception ex) when (ex is IOException or JsonException)
{ {
_logger.LogWarning(ex, "JRay: failed to read managed truth file {Path}", path); _logger.LogWarning(
ex,
"JRay: failed to read managed truth file {Path}. If this is v1 data, re-push it — v1 is no longer read.",
path);
return null; return null;
} }
} }
@@ -39,11 +39,38 @@ public static class ManifestConverter
var truth = new TruthFile var truth = new TruthFile
{ {
SchemaVersion = 1, SchemaVersion = TruthSchema.SupportedVersion,
Movie = mediaPath ?? string.Empty, Movie = mediaPath ?? string.Empty,
SampleFps = manifest.Extraction?.SampleFps ?? 0,
}; };
// Provenance is carried across rather than flattened. The two blocks
// have the same shape by design (JR-002), so anything the server knew
// about how a manifest was produced survives into the stored truth.
if (manifest.Extraction is { } extraction)
{
truth.Extraction = new TruthExtraction
{
SampleFps = extraction.SampleFps,
ExtinctionSec = extraction.ExtinctionSec,
PipelineVersion = extraction.PipelineVersion,
GallerySize = extraction.GallerySize,
GalleryScope = extraction.GalleryScope,
};
}
// The cut is the *manifest's*, not the local file's: it records the
// encode the timings were measured against, which is what makes the
// applied offset interpretable later. Recording the local runtime here
// instead would erase the very discrepancy the offset corrects.
if (manifest.Cut is { } cut)
{
truth.Cut = new TruthCut
{
RuntimeSec = cut.RuntimeSec,
AudioSignature = cut.AudioSignature,
};
}
foreach (var actor in manifest.Actors) foreach (var actor in manifest.Actors)
{ {
var converted = new TruthActor var converted = new TruthActor
@@ -60,7 +87,18 @@ public static class ManifestConverter
// reader can index. // reader can index.
var start = Math.Max(0, scene.Start + offsetSec); var start = Math.Max(0, scene.Start + offsetSec);
var end = Math.Max(start, scene.End + offsetSec); var end = Math.Max(start, scene.End + offsetSec);
converted.Scenes.Add(new[] { start, end }); converted.Scenes.Add(new TruthScene
{
Start = start,
End = end,
// Belief and route survive the conversion. They are what a
// consumer needs to know how far to trust a window, and
// dropping them here would silently downgrade every fetched
// manifest against a locally extracted one.
Belief = scene.Belief,
Route = scene.Route,
});
} }
truth.Actors.Add(converted); truth.Actors.Add(converted);
@@ -48,7 +48,7 @@ public static class PresenceLookup
// scale (see JR-006). // scale (see JR-006).
foreach (var window in actor.Scenes) foreach (var window in actor.Scenes)
{ {
if (window.Length == 2 && window[0] <= t && t <= window[1]) if (window is not null && window.Start <= t && t <= window.End)
{ {
return true; return true;
} }
@@ -101,17 +101,17 @@ public static class PresenceLookup
double previousStart = double.NegativeInfinity; double previousStart = double.NegativeInfinity;
foreach (var window in actor.Scenes) foreach (var window in actor.Scenes)
{ {
if (window.Length != 2) if (window is null)
{ {
continue; continue;
} }
if (window[0] < previousStart) if (window.Start < previousStart)
{ {
return false; return false;
} }
previousStart = window[0]; previousStart = window.Start;
} }
return true; return true;
@@ -83,12 +83,36 @@ public sealed class TruthDataService : ITruthDataService
using var stream = File.OpenRead(truthPath); using var stream = File.OpenRead(truthPath);
var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken) var truth = await JsonSerializer.DeserializeAsync<TruthFile>(stream, JsonOptions, cancellationToken)
.ConfigureAwait(false); .ConfigureAwait(false);
if (!TruthSchema.IsSupported(truth))
{
// Named, not silent. A stale v1 sidecar makes an item look
// un-extracted, and re-extracting a library that has already
// been processed is the most expensive mistake this plugin can
// cause a user to make. The log line is what distinguishes the
// two states.
_logger.LogWarning(
"JRay: ignoring truth file {TruthPath} — schema_version {Found}, expected {Expected}. Re-extract this item.",
truthPath,
truth?.SchemaVersion ?? 0,
TruthSchema.SupportedVersion);
_cache[itemId] = new CacheEntry(null, DateTime.UtcNow);
return null;
}
_cache[itemId] = new CacheEntry(truth, DateTime.UtcNow); _cache[itemId] = new CacheEntry(truth, DateTime.UtcNow);
return truth; return truth;
} }
catch (Exception ex) when (ex is IOException or JsonException) catch (Exception ex) when (ex is IOException or JsonException)
{ {
_logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath); // A v1 file also lands here rather than above: `scenes` was a float
// pair in v1 and is an object in v2, so it fails to deserialise
// before the version can be inspected. Both routes must therefore
// name the file, which is why the message below says the same thing.
_logger.LogWarning(
ex,
"JRay: failed to read truth file {TruthPath}. If this is a v1 file, re-extract the item — v1 is no longer read.",
truthPath);
return null; return null;
} }
} }
@@ -0,0 +1,69 @@
using System.Globalization;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// The one place that decides whether a truth file speaks a version this plugin
/// understands.
/// </summary>
/// <remarks>
/// <b>Flag day, not dual-accept.</b> Only <see cref="SupportedVersion"/> is
/// accepted; every other value is refused on every path — sidecar read, managed
/// <c>PUT</c>, managed store load, and converted manifest. There is no
/// transitional v1 read path.
/// <para>
/// All three components are pre-release and move together, and the alternative
/// carries a cost that outlasts the transition: a v1 read path is the one nobody
/// exercises, so it is the one that rots, and it would have to be dragged
/// through every subsequent change to the reader.
/// </para>
/// <para>
/// <b>The consequence is stated rather than discovered:</b> v1 sidecar files
/// already on disk stop being read at the bump and stay dark until the library
/// is re-extracted. Callers log the rejection naming the file and the version
/// found, because an item that looks un-extracted when it is merely stale is the
/// failure mode that wastes a user's compute.
/// </para>
/// <para>
/// This exists as a shared unit because the check used to live only in the
/// <c>PUT</c> controller while sidecar reads did not check at all — so the
/// format the plugin claimed to require and the format it would actually parse
/// were different things.
/// </para>
/// </remarks>
// TRACES: JR-003 | SR-003
public static class TruthSchema
{
/// <summary>
/// The only <c>schema_version</c> this plugin reads or writes.
/// </summary>
/// <remarks>
/// System-level (SR-003): the same number means the same format in all three
/// repos, and is incremented once per breaking change across all of them.
/// </remarks>
public const int SupportedVersion = 2;
/// <summary>
/// Determines whether a truth file speaks the supported version.
/// </summary>
/// <param name="truth">The parsed truth file, which may be <c>null</c>.</param>
/// <returns><c>true</c> only when the version matches exactly.</returns>
public static bool IsSupported(TruthFile? truth)
=> truth is not null && truth.SchemaVersion == SupportedVersion;
/// <summary>
/// Builds the message describing why a truth file was refused.
/// </summary>
/// <remarks>
/// Names both the version found and the version expected. A rejection that
/// says only "unsupported" leaves the reader unable to tell a stale file
/// from a corrupt one.
/// </remarks>
/// <param name="found">The version encountered.</param>
/// <returns>A message naming both versions.</returns>
public static string DescribeRejection(int found)
=> string.Create(
CultureInfo.InvariantCulture,
$"Unsupported schema_version {found}; expected {SupportedVersion}. Re-extract the item — v1 truth data is no longer read.");
}