Compare commits
2
Commits
305b898b15
...
latest
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5152f6f129 | ||
|
|
c04d5a3dcc |
@@ -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();
|
||||
|
||||
+10
-2
@@ -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.
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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` |
|
||||
|
||||
Reference in New Issue
Block a user