Manifest fetch across the configured servers (JR-025 … JR-037)
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 40s
🧪 Test Plugin / test (push) Successful in 26s

Satisfies JRay-public-server UR-007. Servers are tried in configured order and
the first result clearing the configured tier wins; first-match rather than
best-match because querying every server for every item multiplies egress and
leaks the library to more parties, and the ordering already encodes which
source the admin prefers.

Every server is untrusted, including the pre-configured community one, so a
fetched manifest is re-validated against the same rules the server applies on
upload: envelope version refused if unknown, identifiers format-checked,
windows bounds-checked against the *local* file's runtime, belief bounded to
[0, 1], control and bidi characters refused in names. Responses are capped
while streaming rather than after buffering, since a hostile server can declare
any Content-Length it likes. HTTPS is required away from loopback. A failing
server is skipped with exponential backoff so one dead server cannot stall a
sweep.

The audio-tier offset is applied once, at store time, so stored truth is always
in the local file's own timebase and no read path needs offset awareness.
Windows are shifted, never reshaped — merging adjacent ones would answer "was a
face visible" rather than "was the actor present" (SR-002).

Also records why there is no `exact` tier, which was missing and led me to
re-add one. The file-hash tier is withdrawn on legal grounds: a TMDB id
discloses "some copy of this film", but an OpenSubtitles hash discloses "this
exact release", which turns a catalogue lookup into a release-identification
service and a server's database into a mapping from file fingerprints to the
instances holding them. The reason now lives on MatchTier and in SPEC.md §JR-036,
`TitleQuery` has no VideoHash property so there is nothing to send, and a test
asserts the enum has no Exact member — the spec had still listed `exact` as a
configurable tier, which is what made the removal look like an oversight.

42 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: JR-025, JR-027, JR-028, JR-029, JR-030, JR-031, JR-036, JR-037 | PR-005, PR-006
This commit is contained in:
2026-07-31 10:03:49 +02:00
co-authored by Claude Opus 5
parent 64f549ab35
commit 3d210b5bd3
23 changed files with 1858 additions and 18 deletions
+44
View File
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One shareable actor timeline for one cut of one title, as the public server
/// serves it. See the server specification §2.
/// </summary>
/// <remarks>
/// This is the <b>exchange envelope</b>, versioned by <c>jmanifest_version</c>
/// and deliberately separate from the truth file's <c>schema_version</c>: a
/// change to how manifests are transported need not force a truth-file bump.
/// They currently coincide at 2 only because the SR-003 bump touched both.
/// <para>
/// A manifest is never trusted merely because a server served it (JR-027). Every
/// field below is re-validated on receipt against the same rules the server
/// applies on upload.
/// </para>
/// </remarks>
// TRACES: JR-025, JR-027 | SR-003
public class Jmanifest
{
/// <summary>Gets or sets the exchange envelope version this manifest speaks.</summary>
[JsonPropertyName("jmanifest_version")]
public int JmanifestVersion { get; set; }
/// <summary>Gets or sets what the work is — TMDB/IMDB ids and episode coordinates.</summary>
[JsonPropertyName("identity")]
public JmanifestIdentity? Identity { get; set; }
/// <summary>Gets or sets which encode the timings apply to.</summary>
[JsonPropertyName("cut")]
public JmanifestCut? Cut { get; set; }
/// <summary>Gets or sets extraction provenance.</summary>
[JsonPropertyName("extraction")]
public JmanifestExtraction? Extraction { get; set; }
/// <summary>Gets the actors and their presence windows.</summary>
[JsonPropertyName("actors")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<JmanifestActor> Actors { get; } = new();
}
@@ -0,0 +1,29 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>One actor's timeline within a manifest.</summary>
public class JmanifestActor
{
/// <summary>
/// Gets or sets the display name. Server-authoritative on download: the
/// server resolves each actor to a TMDB person and serves names from its own
/// table, so a name a contributor invented never round-trips.
/// </summary>
[JsonPropertyName("name")]
public string? Name { get; set; }
/// <summary>Gets or sets the IMDB person id.</summary>
[JsonPropertyName("imdb_id")]
public string? ImdbId { get; set; }
/// <summary>Gets or sets the TMDB person id — the primary join key.</summary>
[JsonPropertyName("tmdb_id")]
public string? TmdbId { get; set; }
/// <summary>Gets the presence windows.</summary>
[JsonPropertyName("scenes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<JmanifestScene> Scenes { get; } = new();
}
@@ -0,0 +1,32 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>Cut fingerprint (server spec §2, §3).</summary>
public class JmanifestCut
{
/// <summary>Gets or sets the decoded duration the timings came from.</summary>
[JsonPropertyName("runtime_sec")]
public double RuntimeSec { get; set; }
/// <summary>Gets or sets the container duration, if it differs.</summary>
[JsonPropertyName("container_duration_sec")]
public double? ContainerDurationSec { get; set; }
/// <summary>
/// Gets or sets the OpenSubtitles file hash, as a server may report it.
/// </summary>
/// <remarks>
/// Read-only in practice: the plugin never <em>sends</em> one. The file-hash
/// match tier is withdrawn on legal grounds — see
/// <see cref="Configuration.MatchTier"/> — because a file hash identifies the
/// exact release a user holds rather than the cut the timings describe.
/// </remarks>
[JsonPropertyName("video_hash")]
public string? VideoHash { get; set; }
/// <summary>Gets or sets the version-prefixed spectral-peak signature.</summary>
[JsonPropertyName("audio_signature")]
public string? AudioSignature { get; set; }
}
@@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>Extraction provenance (server spec §2).</summary>
public class JmanifestExtraction
{
/// <summary>Gets or sets the sampling rate used during extraction.</summary>
[JsonPropertyName("sample_fps")]
public double? SampleFps { get; set; }
/// <summary>
/// Gets or sets the re-acquisition timeout that shapes window extent.
/// Successor to the withdrawn <c>anneal_sec</c>.
/// </summary>
[JsonPropertyName("extinction_sec")]
public double? ExtinctionSec { get; set; }
/// <summary>Gets or sets the producing pipeline's version string.</summary>
[JsonPropertyName("pipeline_version")]
public string? PipelineVersion { get; set; }
/// <summary>Gets or sets how many references the gallery held.</summary>
[JsonPropertyName("gallery_size")]
public int? GallerySize { get; set; }
/// <summary>Gets or sets <c>global</c> or <c>limited</c>.</summary>
[JsonPropertyName("gallery_scope")]
public string? GalleryScope { get; set; }
}
@@ -0,0 +1,44 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>Title identity (server spec §2).</summary>
public class JmanifestIdentity
{
/// <summary>Gets or sets <c>movie</c> or <c>episode</c>.</summary>
[JsonPropertyName("type")]
public string Type { get; set; } = string.Empty;
/// <summary>Gets or sets the TMDB id, for a movie.</summary>
[JsonPropertyName("tmdb_id")]
public string? TmdbId { get; set; }
/// <summary>Gets or sets the IMDB id, for a movie.</summary>
[JsonPropertyName("imdb_id")]
public string? ImdbId { get; set; }
/// <summary>Gets or sets the series TMDB id, for an episode.</summary>
[JsonPropertyName("series_tmdb_id")]
public string? SeriesTmdbId { get; set; }
/// <summary>Gets or sets the series IMDB id, for an episode.</summary>
[JsonPropertyName("series_imdb_id")]
public string? SeriesImdbId { get; set; }
/// <summary>Gets or sets the season number, for an episode.</summary>
[JsonPropertyName("season")]
public int? Season { get; set; }
/// <summary>Gets or sets the episode number, for an episode.</summary>
[JsonPropertyName("episode")]
public int? Episode { get; set; }
/// <summary>Gets or sets the display title.</summary>
[JsonPropertyName("title")]
public string? Title { get; set; }
/// <summary>Gets or sets the release year.</summary>
[JsonPropertyName("year")]
public int? Year { get; set; }
}
@@ -0,0 +1,38 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One presence window.
/// </summary>
/// <remarks>
/// <b>A window is a claim about scene membership, not a recognition event</b>
/// (SR-002). An actor who turns away, is occluded, or is off-camera while the
/// shot cuts to whoever they are speaking to is still present — so a consumer
/// must never read a window boundary as "the face was detected here", and must
/// not merge, split or trim windows.
/// </remarks>
public class JmanifestScene
{
/// <summary>Gets or sets the window start, in seconds.</summary>
[JsonPropertyName("start")]
public double Start { get; set; }
/// <summary>Gets or sets the window end, in seconds, inclusive.</summary>
[JsonPropertyName("end")]
public double End { get; set; }
/// <summary>
/// Gets or sets the accumulated posterior that justified this claim, in [0, 1].
/// </summary>
[JsonPropertyName("belief")]
public double? Belief { get; set; }
/// <summary>
/// Gets or sets how the actor was identified: <c>live</c>, <c>deferred</c>
/// or <c>pooled</c>.
/// </summary>
[JsonPropertyName("route")]
public string? Route { get; set; }
}
@@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>The server's reply to a manifest fetch (server spec §4).</summary>
public class ManifestFetchResponse
{
/// <summary>
/// Gets or sets the cut-match tier that was achieved: <c>exact</c>,
/// <c>audio</c>, <c>runtime</c> or <c>loose</c>.
/// </summary>
[JsonPropertyName("match")]
public string Match { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the offset, in seconds, the client must add to every window.
/// </summary>
/// <remarks>
/// Non-zero only for an <c>audio</c>-tier match, where the same cut was
/// found at a different trim. <b>The server returns the offset; the client
/// applies it</b> — manifests are never rewritten, so one stored manifest
/// serves every trim of the same cut.
/// </remarks>
[JsonPropertyName("offset_sec")]
public double OffsetSec { get; set; }
/// <summary>Gets or sets the manifest itself.</summary>
[JsonPropertyName("manifest")]
public Jmanifest? Manifest { get; set; }
}
@@ -0,0 +1,30 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// What a fetch stored, and from where.
/// </summary>
public class ManifestFetchResult
{
/// <summary>Gets or sets the server that supplied the manifest.</summary>
public string ServerUrl { get; set; } = string.Empty;
/// <summary>Gets or sets the cut-match tier achieved.</summary>
public string Match { get; set; } = string.Empty;
/// <summary>Gets or sets the offset applied to every window, in seconds.</summary>
public double OffsetSec { get; set; }
/// <summary>Gets or sets how many actors the stored truth file holds.</summary>
public int ActorCount { get; set; }
/// <summary>
/// Gets or sets a caveat to surface in the UI, or null when the match needs
/// no explanation.
/// </summary>
/// <remarks>
/// A <c>loose</c> match must surface as a caveat rather than being applied
/// silently — the runtimes differ by up to 30 seconds, which is usually a
/// different trim of the same cut but is not guaranteed to be.
/// </remarks>
public string? Caveat { get; set; }
}
@@ -0,0 +1,17 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>A series bundle (server spec §2).</summary>
public class SeriesBundle
{
/// <summary>Gets or sets the envelope version.</summary>
[JsonPropertyName("jmanifest_version")]
public int JmanifestVersion { get; set; }
/// <summary>Gets the episode manifests the server holds.</summary>
[JsonPropertyName("episodes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<Jmanifest> Episodes { get; } = new();
}
@@ -0,0 +1,23 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>What a server says it supports (server spec §9a).</summary>
public class ServerCapabilities
{
/// <summary>Gets or sets the server's own identity.</summary>
[JsonPropertyName("server_id")]
public string? ServerId { get; set; }
/// <summary>
/// Gets the exchange envelope versions the server accepts.
/// </summary>
/// <remarks>
/// Checked once rather than discovered as a rejection per manifest across a
/// whole library sweep.
/// </remarks>
[JsonPropertyName("jmanifest_versions")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<int> JmanifestVersions { get; } = new();
}