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
+6 -2
View File
@@ -45,9 +45,13 @@ public class TruthActor
public string JellyfinId { get; set; } = string.Empty;
/// <summary>
/// Gets the list of [start_sec, end_sec] windows during which the actor is in the scene.
/// Gets the windows during which the actor is in the scene.
/// </summary>
/// <remarks>
/// Objects since <c>schema_version</c> 2, not the float pairs v1 used, so a
/// window can carry the belief and route behind the claim.
/// </remarks>
[JsonPropertyName("scenes")]
[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>
/// 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>
/// <remarks>
/// 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
/// <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,
/// an <c>extraction</c> provenance block and a <c>cut</c> block in, and
/// <c>scenes</c> becoming objects that carry belief and identification route.
/// <para>
/// <b>This is the v2 shape, and v1 is gone rather than deprecated.</b> The bump
/// removed <c>anneal_sec</c>, moved <c>sample_fps</c> into
/// <see cref="TruthExtraction"/>, added <see cref="TruthCut"/>, and turned
/// <c>scenes</c> from float pairs into <see cref="TruthScene"/> objects carrying
/// belief and route. Nothing here reads a v1 file: see
/// <see cref="Services.TruthSchema"/> for why that is a decision rather than an
/// omission.
/// </para>
/// </remarks>
// TRACES: JR-001, JR-002 | SR-003
public class TruthFile
{
/// <summary>
/// Gets or sets the schema version of this file.
/// Gets or sets the schema version of this file. Only
/// <see cref="Services.TruthSchema.SupportedVersion"/> is accepted.
/// </summary>
[JsonPropertyName("schema_version")]
public int SchemaVersion { get; set; }
@@ -29,20 +36,26 @@ public class TruthFile
/// <summary>
/// Gets or sets the source media path at extraction time (informational).
/// </summary>
/// <remarks>
/// Stripped on contribution (JR-034): it is a contributor's directory
/// layout, which is nobody else's business and identifies them.
/// </remarks>
[JsonPropertyName("movie")]
public string Movie { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the sampling rate (frames per second) used during extraction.
/// Gets or sets extraction provenance, or <c>null</c> when the producer
/// recorded none.
/// </summary>
[JsonPropertyName("sample_fps")]
public double SampleFps { get; set; }
[JsonPropertyName("extraction")]
public TruthExtraction? Extraction { get; set; }
/// <summary>
/// Gets or sets the gap (seconds) below which consecutive detections were merged into one scene.
/// Gets or sets which encode the timings apply to, or <c>null</c> when the
/// producer recorded none.
/// </summary>
[JsonPropertyName("anneal_sec")]
public double AnnealSec { get; set; }
[JsonPropertyName("cut")]
public TruthCut? Cut { get; set; }
/// <summary>
/// Gets the list of actors 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; }
}