diff --git a/Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs b/Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs index 0d16d7d..7eeae3a 100644 --- a/Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs +++ b/Jellyfin.Plugin.JRay.Tests/ManagedTruthStoreTests.cs @@ -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; } diff --git a/Jellyfin.Plugin.JRay.Tests/ManifestExchangeTests.cs b/Jellyfin.Plugin.JRay.Tests/ManifestExchangeTests.cs index 12eb3fe..fd9d6b5 100644 --- a/Jellyfin.Plugin.JRay.Tests/ManifestExchangeTests.cs +++ b/Jellyfin.Plugin.JRay.Tests/ManifestExchangeTests.cs @@ -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] diff --git a/Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs b/Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs index 4d2b427..785a0f4 100644 --- a/Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs +++ b/Jellyfin.Plugin.JRay.Tests/PresenceLookupTests.cs @@ -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(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); diff --git a/Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs b/Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs new file mode 100644 index 0000000..5e50fc7 --- /dev/null +++ b/Jellyfin.Plugin.JRay.Tests/TruthSchemaTests.cs @@ -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; + +/// +/// JR-002 (the schema_version 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 +/// +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(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(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(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(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(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(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(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)); + } +} diff --git a/Jellyfin.Plugin.JRay/Controllers/TruthController.cs b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs index 8813d73..19bf559 100644 --- a/Jellyfin.Plugin.JRay/Controllers/TruthController.cs +++ b/Jellyfin.Plugin.JRay/Controllers/TruthController.cs @@ -2,6 +2,7 @@ using System; using System.Threading; using System.Threading.Tasks; using Jellyfin.Plugin.JRay.Models; +using Jellyfin.Plugin.JRay.Services; using Jellyfin.Plugin.JRay.Services.Interfaces; using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Http; @@ -15,10 +16,11 @@ namespace Jellyfin.Plugin.JRay.Controllers; /// locally. See SPEC.md §2. /// /// -/// The schema_version check here refuses an unrecognised version rather -/// than guessing at its shape. It is currently the only source that checks — -/// sidecar reads do not — which JR-003 requires be fixed by moving the check -/// into the shared read path. +/// The schema_version check refuses an unrecognised version rather than +/// guessing at its shape. It defers to rather than +/// holding its own constant: this used to be the only source that checked while +/// sidecar reads did not, so the version the plugin claimed to require and the +/// one it would actually parse could drift apart. /// [ApiController] [Route("Plugins/JRay/Items/{itemId}/Truth")] @@ -26,8 +28,6 @@ namespace Jellyfin.Plugin.JRay.Controllers; // TRACES: JR-003, JR-009, JR-014 | SR-003 public class TruthController : ControllerBase { - private const int SupportedSchemaVersion = 1; - private readonly IManagedTruthStore _managedTruthStore; private readonly ITruthDataService _truthDataService; @@ -54,9 +54,9 @@ public class TruthController : ControllerBase [ProducesResponseType(StatusCodes.Status400BadRequest)] public async Task 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); diff --git a/Jellyfin.Plugin.JRay/Models/TruthActor.cs b/Jellyfin.Plugin.JRay/Models/TruthActor.cs index 67e3955..ee8af0c 100644 --- a/Jellyfin.Plugin.JRay/Models/TruthActor.cs +++ b/Jellyfin.Plugin.JRay/Models/TruthActor.cs @@ -45,9 +45,13 @@ public class TruthActor public string JellyfinId { get; set; } = string.Empty; /// - /// 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. /// + /// + /// Objects since schema_version 2, not the float pairs v1 used, so a + /// window can carry the belief and route behind the claim. + /// [JsonPropertyName("scenes")] [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)] - public Collection Scenes { get; } = new(); + public Collection Scenes { get; } = new(); } diff --git a/Jellyfin.Plugin.JRay/Models/TruthCut.cs b/Jellyfin.Plugin.JRay/Models/TruthCut.cs new file mode 100644 index 0000000..6079620 --- /dev/null +++ b/Jellyfin.Plugin.JRay/Models/TruthCut.cs @@ -0,0 +1,35 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.JRay.Models; + +/// +/// Which encode the timings in a apply to (SPEC.md §1, +/// JR-002). +/// +/// +/// 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. +/// +// TRACES: JR-002 | SR-003 +public class TruthCut +{ + /// + /// Gets or sets the decoded duration the timings came from, in seconds. + /// + [JsonPropertyName("runtime_sec")] + public double? RuntimeSec { get; set; } + + /// + /// Gets or sets the version-prefixed spectral-peak audio signature, or + /// null when the producer emitted none. + /// + /// + /// Carries its own v1: 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). + /// + [JsonPropertyName("audio_signature")] + public string? AudioSignature { get; set; } +} diff --git a/Jellyfin.Plugin.JRay/Models/TruthExtraction.cs b/Jellyfin.Plugin.JRay/Models/TruthExtraction.cs new file mode 100644 index 0000000..d374d5b --- /dev/null +++ b/Jellyfin.Plugin.JRay/Models/TruthExtraction.cs @@ -0,0 +1,53 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.JRay.Models; + +/// +/// Extraction provenance for a (SPEC.md §1, JR-002). +/// +/// +/// These fields moved here from the top level in the SR-003 bump so that the +/// truth file and the Jmanifest's extraction block have the same shape. +/// They differed for no reason, and two nearly-identical shapes are what makes a +/// converter quietly drop a field. +/// +/// There is no anneal_sec. 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. +/// +/// +// TRACES: JR-002 | SR-003 +public class TruthExtraction +{ + /// Gets or sets the sampling rate used during extraction. + [JsonPropertyName("sample_fps")] + public double? SampleFps { get; set; } + + /// + /// Gets or sets the re-acquisition timeout that shapes window extent, in + /// seconds. Successor to the withdrawn anneal_sec. + /// + /// + /// 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. + /// + [JsonPropertyName("extinction_sec")] + public double? ExtinctionSec { get; set; } + + /// Gets or sets the producing pipeline's version string. + [JsonPropertyName("pipeline_version")] + public string? PipelineVersion { get; set; } + + /// Gets or sets how many references the gallery held. + [JsonPropertyName("gallery_size")] + public int? GallerySize { get; set; } + + /// + /// Gets or sets global or limited — the strongest single + /// quality signal when two manifests compete for one cut. + /// + [JsonPropertyName("gallery_scope")] + public string? GalleryScope { get; set; } +} diff --git a/Jellyfin.Plugin.JRay/Models/TruthFile.cs b/Jellyfin.Plugin.JRay/Models/TruthFile.cs index 1e7511d..1481aa5 100644 --- a/Jellyfin.Plugin.JRay/Models/TruthFile.cs +++ b/Jellyfin.Plugin.JRay/Models/TruthFile.cs @@ -5,7 +5,7 @@ namespace Jellyfin.Plugin.JRay.Models; /// /// Root object of a scene-actor-extraction "truth" file -/// (schema_version 1, minimal verbosity). See SPEC.md §1. +/// (schema_version 2). See SPEC.md §1. /// /// /// JRay owns 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 /// schema_version bump rather than made piecemeal. /// -/// This type is still the v1 shape. JR-002 replaces it: anneal_sec out, -/// an extraction provenance block and a cut block in, and -/// scenes becoming objects that carry belief and identification route. +/// +/// This is the v2 shape, and v1 is gone rather than deprecated. The bump +/// removed anneal_sec, moved sample_fps into +/// , added , and turned +/// scenes from float pairs into objects carrying +/// belief and route. Nothing here reads a v1 file: see +/// for why that is a decision rather than an +/// omission. +/// /// // TRACES: JR-001, JR-002 | SR-003 public class TruthFile { /// - /// Gets or sets the schema version of this file. + /// Gets or sets the schema version of this file. Only + /// is accepted. /// [JsonPropertyName("schema_version")] public int SchemaVersion { get; set; } @@ -29,20 +36,26 @@ public class TruthFile /// /// Gets or sets the source media path at extraction time (informational). /// + /// + /// Stripped on contribution (JR-034): it is a contributor's directory + /// layout, which is nobody else's business and identifies them. + /// [JsonPropertyName("movie")] public string Movie { get; set; } = string.Empty; /// - /// Gets or sets the sampling rate (frames per second) used during extraction. + /// Gets or sets extraction provenance, or null when the producer + /// recorded none. /// - [JsonPropertyName("sample_fps")] - public double SampleFps { get; set; } + [JsonPropertyName("extraction")] + public TruthExtraction? Extraction { get; set; } /// - /// 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 null when the + /// producer recorded none. /// - [JsonPropertyName("anneal_sec")] - public double AnnealSec { get; set; } + [JsonPropertyName("cut")] + public TruthCut? Cut { get; set; } /// /// Gets the list of actors in the film, each with their scene-presence windows. diff --git a/Jellyfin.Plugin.JRay/Models/TruthScene.cs b/Jellyfin.Plugin.JRay/Models/TruthScene.cs new file mode 100644 index 0000000..6969861 --- /dev/null +++ b/Jellyfin.Plugin.JRay/Models/TruthScene.cs @@ -0,0 +1,50 @@ +using System.Text.Json.Serialization; + +namespace Jellyfin.Plugin.JRay.Models; + +/// +/// One presence window in a . +/// +/// +/// A window is a claim about scene membership, not a recognition event +/// (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. +/// +/// In schema_version 1 this was a bare [start, end] 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. +/// +/// +// TRACES: JR-002, JR-004 | SR-002, SR-003 +public class TruthScene +{ + /// Gets or sets the window start, in seconds, inclusive. + [JsonPropertyName("start")] + public double Start { get; set; } + + /// Gets or sets the window end, in seconds, inclusive. + [JsonPropertyName("end")] + public double End { get; set; } + + /// + /// Gets or sets the accumulated posterior that justified this claim, in + /// [0, 1], or null when the producer did not record one. + /// + /// + /// 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. + /// + [JsonPropertyName("belief")] + public double? Belief { get; set; } + + /// + /// Gets or sets how the actor was identified: live, deferred + /// or pooled (extraction AR-017). + /// + [JsonPropertyName("route")] + public string? Route { get; set; } +} diff --git a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs index fb05c40..9363990 100644 --- a/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs +++ b/Jellyfin.Plugin.JRay/Services/ManagedTruthStore.cs @@ -49,12 +49,30 @@ public sealed class ManagedTruthStore : IManagedTruthStore try { using var stream = File.OpenRead(path); - return await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) + var truth = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) .ConfigureAwait(false); + + // Managed truth is checked on the way out as well as on the way in. + // Data written by an earlier plugin version is already on disk, and + // it did not pass today's PUT. + if (!TruthSchema.IsSupported(truth)) + { + _logger.LogWarning( + "JRay: ignoring managed truth {Path} — schema_version {Found}, expected {Expected}. Re-push or re-extract this item.", + path, + truth?.SchemaVersion ?? 0, + TruthSchema.SupportedVersion); + return null; + } + + return truth; } catch (Exception ex) when (ex is IOException or JsonException) { - _logger.LogWarning(ex, "JRay: failed to read managed truth file {Path}", path); + _logger.LogWarning( + ex, + "JRay: failed to read managed truth file {Path}. If this is v1 data, re-push it — v1 is no longer read.", + path); return null; } } diff --git a/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs b/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs index 090b706..2ea8922 100644 --- a/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs +++ b/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs @@ -39,11 +39,38 @@ public static class ManifestConverter var truth = new TruthFile { - SchemaVersion = 1, + SchemaVersion = TruthSchema.SupportedVersion, 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) { var converted = new TruthActor @@ -60,7 +87,18 @@ public static class ManifestConverter // 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 }); + 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); diff --git a/Jellyfin.Plugin.JRay/Services/PresenceLookup.cs b/Jellyfin.Plugin.JRay/Services/PresenceLookup.cs index 3a9ac44..9334a52 100644 --- a/Jellyfin.Plugin.JRay/Services/PresenceLookup.cs +++ b/Jellyfin.Plugin.JRay/Services/PresenceLookup.cs @@ -48,7 +48,7 @@ public static class PresenceLookup // scale (see JR-006). 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; } @@ -101,17 +101,17 @@ public static class PresenceLookup double previousStart = double.NegativeInfinity; foreach (var window in actor.Scenes) { - if (window.Length != 2) + if (window is null) { continue; } - if (window[0] < previousStart) + if (window.Start < previousStart) { return false; } - previousStart = window[0]; + previousStart = window.Start; } return true; diff --git a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs index 309abc0..4d91d3d 100644 --- a/Jellyfin.Plugin.JRay/Services/TruthDataService.cs +++ b/Jellyfin.Plugin.JRay/Services/TruthDataService.cs @@ -83,12 +83,36 @@ public sealed class TruthDataService : ITruthDataService using var stream = File.OpenRead(truthPath); var truth = await JsonSerializer.DeserializeAsync(stream, JsonOptions, cancellationToken) .ConfigureAwait(false); + + if (!TruthSchema.IsSupported(truth)) + { + // Named, not silent. A stale v1 sidecar makes an item look + // un-extracted, and re-extracting a library that has already + // been processed is the most expensive mistake this plugin can + // cause a user to make. The log line is what distinguishes the + // two states. + _logger.LogWarning( + "JRay: ignoring truth file {TruthPath} — schema_version {Found}, expected {Expected}. Re-extract this item.", + truthPath, + truth?.SchemaVersion ?? 0, + TruthSchema.SupportedVersion); + _cache[itemId] = new CacheEntry(null, DateTime.UtcNow); + return null; + } + _cache[itemId] = new CacheEntry(truth, DateTime.UtcNow); return truth; } catch (Exception ex) when (ex is IOException or JsonException) { - _logger.LogWarning(ex, "JRay: failed to read truth file {TruthPath}", truthPath); + // A v1 file also lands here rather than above: `scenes` was a float + // pair in v1 and is an object in v2, so it fails to deserialise + // before the version can be inspected. Both routes must therefore + // name the file, which is why the message below says the same thing. + _logger.LogWarning( + ex, + "JRay: failed to read truth file {TruthPath}. If this is a v1 file, re-extract the item — v1 is no longer read.", + truthPath); return null; } } diff --git a/Jellyfin.Plugin.JRay/Services/TruthSchema.cs b/Jellyfin.Plugin.JRay/Services/TruthSchema.cs new file mode 100644 index 0000000..99bd44c --- /dev/null +++ b/Jellyfin.Plugin.JRay/Services/TruthSchema.cs @@ -0,0 +1,69 @@ +using System.Globalization; +using Jellyfin.Plugin.JRay.Models; + +namespace Jellyfin.Plugin.JRay.Services; + +/// +/// The one place that decides whether a truth file speaks a version this plugin +/// understands. +/// +/// +/// Flag day, not dual-accept. Only is +/// accepted; every other value is refused on every path — sidecar read, managed +/// PUT, managed store load, and converted manifest. There is no +/// transitional v1 read path. +/// +/// 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. +/// +/// +/// The consequence is stated rather than discovered: 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. +/// +/// +/// This exists as a shared unit because the check used to live only in the +/// PUT 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. +/// +/// +// TRACES: JR-003 | SR-003 +public static class TruthSchema +{ + /// + /// The only schema_version this plugin reads or writes. + /// + /// + /// 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. + /// + public const int SupportedVersion = 2; + + /// + /// Determines whether a truth file speaks the supported version. + /// + /// The parsed truth file, which may be null. + /// true only when the version matches exactly. + public static bool IsSupported(TruthFile? truth) + => truth is not null && truth.SchemaVersion == SupportedVersion; + + /// + /// Builds the message describing why a truth file was refused. + /// + /// + /// 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. + /// + /// The version encountered. + /// A message naming both versions. + 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."); +}