2 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
19 changed files with 824 additions and 57 deletions
@@ -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,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");
}
}
@@ -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,
@@ -105,7 +105,17 @@ public class ManifestController : ControllerBase
// 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);
await _truthStore.SaveAsync(itemId, truth, cancellationToken).ConfigureAwait(false);
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)",
@@ -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();
@@ -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();
}
+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 };
}
@@ -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.
@@ -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,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;
}
}
@@ -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
+46 -15
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
+32 -11
View File
@@ -35,13 +35,34 @@ Tag code with `// TRACES: JR-012 | SR-002`.
| 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 15 execute and pass. The suite was also checked to **fail** on two separate
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: removing the newline-stripping from `RemoveInjection` fails
UT-001 alone, and downgrading the missing-dependency warning to `Information`
fails UT-013 alone. In both cases the blast radius was one test, and the source
was restored and re-verified.
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
@@ -62,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)
@@ -73,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)
@@ -246,11 +267,11 @@ framework reference and leans on `RollForward` to reach the 10.0 runtime.
| 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` |