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>
This commit is contained in:
@@ -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;
|
||||
@@ -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,7 @@ public class ActorsController : ControllerBase
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Gets the JRay context (currently: on-screen actors) at a given timestamp.
|
||||
/// 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 +78,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,
|
||||
|
||||
+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,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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
+24
-9
@@ -35,13 +35,28 @@ 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** |
|
||||
|
||||
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 |
|
||||
|
||||
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 +77,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)
|
||||
@@ -246,7 +261,7 @@ 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` |
|
||||
|
||||
Reference in New Issue
Block a user