diff --git a/Jellyfin.Plugin.JRay.Tests/ManifestExchangeTests.cs b/Jellyfin.Plugin.JRay.Tests/ManifestExchangeTests.cs
new file mode 100644
index 0000000..12eb3fe
--- /dev/null
+++ b/Jellyfin.Plugin.JRay.Tests/ManifestExchangeTests.cs
@@ -0,0 +1,319 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using Jellyfin.Plugin.JRay.Configuration;
+using Jellyfin.Plugin.JRay.Models;
+using Jellyfin.Plugin.JRay.Services;
+using Xunit;
+
+namespace Jellyfin.Plugin.JRay.Tests;
+
+///
+/// Tests for the manifest exchange: tier policy, transport rules, validation on
+/// receipt, and offset application.
+///
+public class ManifestExchangeTests
+{
+ private static ManifestServer Server(string url) =>
+ new() { Url = url, Name = "test", Enabled = true };
+
+ private static TitleQuery Query() =>
+ new() { TmdbId = "504172", RuntimeSec = 6420.5 };
+
+ // -----------------------------------------------------------------------
+ // The withdrawn file-hash tier
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void MatchTierHasNoExactMember()
+ {
+ // The `exact` tier keyed on an OpenSubtitles file hash and was withdrawn
+ // on legal grounds: a file hash identifies the exact release a user
+ // holds, not the cut the timings describe, so sending one turns a
+ // catalogue lookup into a release-identification service.
+ //
+ // Asserted on the enum rather than trusted, because "we removed it" is
+ // exactly the kind of decision a later reader re-adds as an oversight.
+ var names = Enum.GetNames();
+ Assert.DoesNotContain("Exact", names);
+ Assert.Equal(new[] { "Loose", "Runtime", "Audio" }, names);
+ }
+
+ [Fact]
+ public void AudioIsTheTopTier()
+ {
+ // Content-derived, so it identifies the cut rather than the copy — which
+ // is what makes it an acceptable replacement for the file hash.
+ Assert.True(MatchTier.Audio > MatchTier.Runtime);
+ Assert.True(MatchTier.Runtime > MatchTier.Loose);
+ Assert.Equal(MatchTier.Audio, Enum.GetValues().Max());
+ }
+
+ [Fact]
+ public void NoVideoHashIsEverSent()
+ {
+ // Structural, not merely policy: `TitleQuery` has no VideoHash property,
+ // so there is nothing a future caller could populate.
+ Assert.Null(typeof(TitleQuery).GetProperty("VideoHash"));
+
+ var query = new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 };
+ Assert.DoesNotContain("video_hash", query.ToQueryString(), StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void AServerReportingTheWithdrawnTierIsDeclined()
+ {
+ // A server may still hold hashes from other clients. If one somehow
+ // reports `exact`, it is unrecognised rather than silently accepted
+ // under a tier this plugin has no policy for.
+ Assert.Null(ManifestExchangeClient.ParseTier("exact"));
+ Assert.Equal(MatchTier.Audio, ManifestExchangeClient.ParseTier("audio"));
+ Assert.Equal(MatchTier.Runtime, ManifestExchangeClient.ParseTier("runtime"));
+ Assert.Equal(MatchTier.Loose, ManifestExchangeClient.ParseTier("loose"));
+ Assert.Null(ManifestExchangeClient.ParseTier("nonsense"));
+ }
+
+ // -----------------------------------------------------------------------
+ // Transport
+ // -----------------------------------------------------------------------
+
+ [Theory]
+ [InlineData("https://jray.tourolle.paris", true)]
+ [InlineData("https://third.party.example", true)]
+ [InlineData("http://127.0.0.1:8080", true)]
+ [InlineData("http://localhost:8080", true)]
+ [InlineData("http://jray.tourolle.paris", false)]
+ [InlineData("http://192.168.1.10:8080", false)]
+ [InlineData("ftp://example.com", false)]
+ public void HttpsIsRequiredAwayFromLoopback(string url, bool acceptable)
+ {
+ // A plaintext server would let any network intermediary rewrite actor
+ // overlays, and the overlay is shown to the user as fact. Loopback is
+ // exempt because there is no network path to intercept.
+ Assert.Equal(acceptable, ManifestExchangeClient.IsTransportAcceptable(new Uri(url)));
+ }
+
+ [Fact]
+ public void AnUnusableServerUrlYieldsNoRequest()
+ {
+ Assert.Null(ManifestExchangeClient.BuildUrl(Server("http://example.com"), "manifests/movie", Query()));
+ Assert.Null(ManifestExchangeClient.BuildUrl(Server("not a url"), "manifests/movie", Query()));
+ }
+
+ [Fact]
+ public void QueryParametersAreEscaped()
+ {
+ var url = ManifestExchangeClient.BuildUrl(
+ Server("https://s.example/"),
+ "manifests/movie",
+ new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 });
+
+ Assert.NotNull(url);
+ Assert.StartsWith("https://s.example/api/v1/manifests/movie?", url!.AbsoluteUri, StringComparison.Ordinal);
+ Assert.Contains("tmdb_id=504172", url.AbsoluteUri, StringComparison.Ordinal);
+ // Invariant formatting, so a comma-decimal locale cannot corrupt the runtime.
+ Assert.Contains("runtime_sec=6420.5", url.AbsoluteUri, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void EpisodeCoordinatesAreSent()
+ {
+ var url = ManifestExchangeClient.BuildUrl(
+ Server("https://s.example"),
+ "manifests/episode",
+ new TitleQuery { SeriesTmdbId = "1396", Season = 2, Episode = 5, RuntimeSec = 2820 });
+
+ Assert.NotNull(url);
+ Assert.Contains("series_tmdb_id=1396", url!.AbsoluteUri, StringComparison.Ordinal);
+ Assert.Contains("season=2", url.AbsoluteUri, StringComparison.Ordinal);
+ Assert.Contains("episode=5", url.AbsoluteUri, StringComparison.Ordinal);
+ }
+
+ // -----------------------------------------------------------------------
+ // Validation on receipt — every server is untrusted
+ // -----------------------------------------------------------------------
+
+ private static Jmanifest ValidManifest()
+ {
+ var m = new Jmanifest { JmanifestVersion = 2 };
+ m.Identity = new JmanifestIdentity { Type = "movie", TmdbId = "504172" };
+ m.Cut = new JmanifestCut { RuntimeSec = 6420.5 };
+ var actor = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
+ actor.Scenes.Add(new JmanifestScene { Start = 191.6, End = 209.2, Belief = 0.98, Route = "live" });
+ m.Actors.Add(actor);
+ return m;
+ }
+
+ [Fact]
+ public void AWellFormedManifestValidates()
+ {
+ Assert.True(ManifestValidator.TryValidate(ValidManifest(), 6420.5, out var error), error);
+ }
+
+ [Fact]
+ public void AnUnknownEnvelopeVersionIsRefused()
+ {
+ // Never guessed at: a server one version ahead may have changed the
+ // meaning of a field this plugin thinks it understands.
+ foreach (var version in new[] { 0, 1, 3, 99 })
+ {
+ var m = ValidManifest();
+ m.JmanifestVersion = version;
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
+ Assert.Contains("jmanifest_version", error, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void WindowsBeyondTheLocalRuntimeAreRejected()
+ {
+ // Bounds are checked against the *local* file, because that is what the
+ // overlay indexes into. A window past the end is evidence the manifest
+ // describes another cut.
+ var m = ValidManifest();
+ m.Actors[0].Scenes.Clear();
+ m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 9000 });
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
+ Assert.Contains("runtime", error, StringComparison.Ordinal);
+ }
+
+ [Fact]
+ public void InvertedNegativeAndNonFiniteWindowsAreRejected()
+ {
+ foreach (var scene in new[]
+ {
+ new JmanifestScene { Start = 50, End = 10 },
+ new JmanifestScene { Start = -1, End = 10 },
+ new JmanifestScene { Start = double.NaN, End = 10 },
+ new JmanifestScene { Start = 0, End = double.PositiveInfinity },
+ })
+ {
+ var m = ValidManifest();
+ m.Actors[0].Scenes.Clear();
+ m.Actors[0].Scenes.Add(scene);
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
+ }
+ }
+
+ [Fact]
+ public void BeliefOutsideZeroToOneIsRejected()
+ {
+ foreach (var belief in new[] { -0.1, 1.5, double.NaN })
+ {
+ var m = ValidManifest();
+ m.Actors[0].Scenes[0].Belief = belief;
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
+ Assert.Contains("belief", error, StringComparison.Ordinal);
+ }
+ }
+
+ [Fact]
+ public void MalformedIdentifiersAreRejected()
+ {
+ var m = ValidManifest();
+ m.Actors[0].TmdbId = "884'; DROP TABLE--";
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
+
+ m = ValidManifest();
+ m.Actors[0].ImdbId = "tt0000114"; // a title id in a person field
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
+ }
+
+ [Fact]
+ public void ControlCharactersInANameAreRejected()
+ {
+ // The overlay renders names as text nodes, so markup is already inert —
+ // but a bidi override still makes a name display as something other than
+ // what was stored.
+ foreach (var name in new[] { "SteveBuscemi", "SteveimecsuB", "SteveBuscemi" })
+ {
+ var m = ValidManifest();
+ m.Actors[0].Name = name;
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
+ }
+ }
+
+ [Fact]
+ public void RealNamesAreAccepted()
+ {
+ foreach (var name in new[] { "Steve Buscemi", "Renée Zellweger", "宮崎 駿", "O'Brien" })
+ {
+ var m = ValidManifest();
+ m.Actors[0].Name = name;
+ Assert.True(ManifestValidator.TryValidate(m, 6420.5, out var error), $"{name}: {error}");
+ }
+ }
+
+ [Fact]
+ public void DuplicateActorsAreRejected()
+ {
+ var m = ValidManifest();
+ var dup = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
+ dup.Scenes.Add(new JmanifestScene { Start = 1, End = 2 });
+ m.Actors.Add(dup);
+ Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
+ }
+
+ // -----------------------------------------------------------------------
+ // Offset application
+ // -----------------------------------------------------------------------
+
+ [Fact]
+ public void TheOffsetIsAppliedToEveryWindow()
+ {
+ // Applied once, at store time, so the stored truth is always in the
+ // local file's timebase and no reader needs offset awareness.
+ var m = ValidManifest();
+ m.Actors[0].Scenes.Clear();
+ m.Actors[0].Scenes.Add(new JmanifestScene { Start = 100, End = 120 });
+ m.Actors[0].Scenes.Add(new JmanifestScene { Start = 200, End = 220 });
+
+ var truth = ManifestConverter.ToTruthFile(m, 40, "/media/film.mkv");
+
+ Assert.Equal(new[] { 140.0, 160.0 }, truth.Actors[0].Scenes[0]);
+ Assert.Equal(new[] { 240.0, 260.0 }, truth.Actors[0].Scenes[1]);
+ }
+
+ [Fact]
+ public void ANegativeOffsetCannotPushAWindowBelowZero()
+ {
+ // A start before the file begins is not indexable by any reader.
+ var m = ValidManifest();
+ m.Actors[0].Scenes.Clear();
+ m.Actors[0].Scenes.Add(new JmanifestScene { Start = 5, End = 20 });
+
+ var truth = ManifestConverter.ToTruthFile(m, -40, "/media/film.mkv");
+
+ Assert.Equal(0.0, truth.Actors[0].Scenes[0][0]);
+ Assert.True(truth.Actors[0].Scenes[0][1] >= truth.Actors[0].Scenes[0][0]);
+ }
+
+ [Fact]
+ public void WindowsAreShiftedNeverReshaped()
+ {
+ // A window is a claim about scene membership (SR-002), so merging
+ // adjacent windows would answer a different question than the pipeline
+ // answered — "was a face visible" rather than "was the actor present".
+ var m = ValidManifest();
+ m.Actors[0].Scenes.Clear();
+ m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 20 });
+ m.Actors[0].Scenes.Add(new JmanifestScene { Start = 20, End = 30 });
+
+ var truth = ManifestConverter.ToTruthFile(m, 0, "/media/film.mkv");
+
+ Assert.Equal(2, truth.Actors[0].Scenes.Count);
+ Assert.Equal(new[] { 10.0, 20.0 }, truth.Actors[0].Scenes[0]);
+ Assert.Equal(new[] { 20.0, 30.0 }, truth.Actors[0].Scenes[1]);
+ }
+
+ [Fact]
+ public void ALooseMatchSurfacesACaveat()
+ {
+ // Applied as a caveat rather than 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.
+ Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Loose, 0));
+ Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Audio, 40));
+ Assert.Null(ManifestConverter.DescribeCaveat(MatchTier.Runtime, 0));
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs b/Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs
index a7f4f5b..b68cb76 100644
--- a/Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs
+++ b/Jellyfin.Plugin.JRay/Configuration/ManifestServer.cs
@@ -23,6 +23,28 @@ public enum ServerTrustLevel
/// The minimum cut-match tier a fetched manifest must reach before it is stored.
/// See the public server specification, §3 "Cut matching".
///
+///
+/// Every tier is a claim about a cut, never about a copy. There is no
+/// file-level tier, and the plugin sends no video_hash.
+///
+/// The Exact tier was withdrawn for legal reasons; do not re-add it
+/// without an explicit, recorded agreement. It keyed on the OpenSubtitles
+/// file hash, which identifies the individual encode a user holds rather than
+/// the edit the timings describe. A TMDB id discloses "some copy of this film";
+/// a file hash discloses "this exact release", which turns a catalogue
+/// lookup into a release-identification service and turns the server's database
+/// into a mapping from file fingerprints to the instances holding them. That is
+/// a far more specific disclosure than PR-005 permits, and a dataset no
+/// volunteer operator should be holding.
+///
+///
+/// The audio signature is the deliberate replacement: derived from content, it
+/// identifies the cut, so two different encodes of the same edit agree.
+/// It answers the question the exchange needs — "do these timings apply to this
+/// media?" — without answering the one it must not. Audio is therefore
+/// the top tier here.
+///
+///
public enum MatchTier
{
/// Audio 0.60–0.85, or runtimes within ±30s. Surfaced as a caveat in the UI.
@@ -31,11 +53,8 @@ public enum MatchTier
/// Runtimes within ±2s.
Runtime = 1,
- /// Audio signature score ≥ 0.85; may carry a non-zero offset.
+ /// Audio signature score ≥ 0.85; may carry a non-zero offset. The top tier.
Audio = 2,
-
- /// Identical video_hash — the same file.
- Exact = 3,
}
///
diff --git a/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs b/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs
new file mode 100644
index 0000000..512cccd
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs
@@ -0,0 +1,190 @@
+using System;
+using System.Collections.Generic;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.JRay.Configuration;
+using Jellyfin.Plugin.JRay.Models;
+using Jellyfin.Plugin.JRay.Services;
+using Jellyfin.Plugin.JRay.Services.Interfaces;
+using MediaBrowser.Common.Api;
+using MediaBrowser.Controller.Entities;
+using MediaBrowser.Controller.Entities.TV;
+using MediaBrowser.Controller.Library;
+using Microsoft.AspNetCore.Authorization;
+using Microsoft.AspNetCore.Http;
+using Microsoft.AspNetCore.Mvc;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.JRay.Controllers;
+
+///
+/// Fetches actor-timeline manifests from the configured public servers.
+///
+// TRACES: JR-025, JR-031 | PR-006
+[ApiController]
+[Authorize(Policy = Policies.RequiresElevation)]
+[Route("Plugins/JRay")]
+[Produces("application/json")]
+public class ManifestController : ControllerBase
+{
+ private readonly ILibraryManager _libraryManager;
+ private readonly IManifestExchangeClient _exchange;
+ private readonly IManagedTruthStore _truthStore;
+ private readonly ILogger _logger;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Library manager.
+ /// Manifest exchange client.
+ /// Managed truth store.
+ /// Logger.
+ public ManifestController(
+ ILibraryManager libraryManager,
+ IManifestExchangeClient exchange,
+ IManagedTruthStore truthStore,
+ ILogger logger)
+ {
+ _libraryManager = libraryManager;
+ _exchange = exchange;
+ _truthStore = truthStore;
+ _logger = logger;
+ }
+
+ ///
+ /// Resolves an item across the configured servers and stores the first
+ /// acceptable manifest.
+ ///
+ /// The library item id.
+ /// Cancellation token.
+ /// What was fetched, and from where.
+ /// A manifest was stored.
+ /// The item does not exist, or no server had a manifest for it.
+ /// Manifest sharing is disabled in the plugin configuration.
+ [HttpPost("Items/{itemId}/Fetch")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ [ProducesResponseType(StatusCodes.Status404NotFound)]
+ [ProducesResponseType(StatusCodes.Status409Conflict)]
+ public async Task> FetchItem(
+ [FromRoute] Guid itemId,
+ CancellationToken cancellationToken)
+ {
+ var config = Plugin.Instance?.Configuration;
+ if (config is null || !config.EnableManifestSharing)
+ {
+ // Off by default and opt-in: this is a network egress feature, so it
+ // never runs merely because an endpoint was called.
+ return Conflict(new { error = "manifest sharing is disabled" });
+ }
+
+ var item = _libraryManager.GetItemById(itemId);
+ if (item is null)
+ {
+ return NotFound();
+ }
+
+ var query = BuildQuery(item);
+ if (query is null)
+ {
+ return NotFound(new { error = "item has no TMDB or IMDB id to look up" });
+ }
+
+ var servers = config.Servers.ToList();
+ var outcome = item is Episode
+ ? await _exchange.FetchEpisodeAsync(servers, config.MinimumMatchTier, query, cancellationToken)
+ .ConfigureAwait(false)
+ : await _exchange.FetchMovieAsync(servers, config.MinimumMatchTier, query, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (outcome?.Manifest is null)
+ {
+ return NotFound(new { error = "no configured server had an acceptable manifest" });
+ }
+
+ // 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);
+
+ _logger.LogInformation(
+ "Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s)",
+ itemId,
+ outcome.ServerUrl,
+ outcome.Tier,
+ outcome.OffsetSec);
+
+ return Ok(new ManifestFetchResult
+ {
+ ServerUrl = outcome.ServerUrl,
+ Match = outcome.Tier.ToString().ToLowerInvariant(),
+ OffsetSec = outcome.OffsetSec,
+ ActorCount = truth.Actors.Count,
+ Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec),
+ });
+ }
+
+ ///
+ /// Per-server reachability and last error, for the configuration page.
+ ///
+ /// One entry per configured server, in configured order.
+ /// Status for each configured server.
+ [HttpGet("Servers/Status")]
+ [ProducesResponseType(StatusCodes.Status200OK)]
+ public ActionResult> GetServerStatus()
+ {
+ var config = Plugin.Instance?.Configuration;
+ if (config is null)
+ {
+ return Ok(Array.Empty());
+ }
+
+ return Ok(_exchange.GetStatus(config.Servers.ToList()));
+ }
+
+ ///
+ /// Reads a provider id from an item, or null when it is absent.
+ ///
+ private static string? ProviderId(BaseItem? item, string provider) =>
+ item?.ProviderIds is { } ids && ids.TryGetValue(provider, out var value)
+ && !string.IsNullOrWhiteSpace(value)
+ ? value
+ : null;
+
+ ///
+ /// Builds the lookup query from an item's provider ids and measured runtime.
+ ///
+ private static TitleQuery? BuildQuery(BaseItem item)
+ {
+ var runtimeSec = item.RunTimeTicks.HasValue
+ ? TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalSeconds
+ : (double?)null;
+
+ if (item is Episode episode)
+ {
+ var series = episode.Series;
+ var seriesTmdb = ProviderId(series, "Tmdb");
+ if (string.IsNullOrEmpty(seriesTmdb))
+ {
+ return null;
+ }
+
+ return new TitleQuery
+ {
+ SeriesTmdbId = seriesTmdb,
+ Season = episode.ParentIndexNumber,
+ Episode = episode.IndexNumber,
+ RuntimeSec = runtimeSec,
+ };
+ }
+
+ var tmdb = ProviderId(item, "Tmdb");
+ var imdb = ProviderId(item, "Imdb");
+ if (string.IsNullOrEmpty(tmdb) && string.IsNullOrEmpty(imdb))
+ {
+ return null;
+ }
+
+ return new TitleQuery { TmdbId = tmdb, ImdbId = imdb, RuntimeSec = runtimeSec };
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/Jmanifest.cs b/Jellyfin.Plugin.JRay/Models/Jmanifest.cs
new file mode 100644
index 0000000..093ae34
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/Jmanifest.cs
@@ -0,0 +1,44 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// One shareable actor timeline for one cut of one title, as the public server
+/// serves it. See the server specification §2.
+///
+///
+/// This is the exchange envelope, versioned by jmanifest_version
+/// and deliberately separate from the truth file's schema_version: 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.
+///
+/// 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.
+///
+///
+// TRACES: JR-025, JR-027 | SR-003
+public class Jmanifest
+{
+ /// Gets or sets the exchange envelope version this manifest speaks.
+ [JsonPropertyName("jmanifest_version")]
+ public int JmanifestVersion { get; set; }
+
+ /// Gets or sets what the work is — TMDB/IMDB ids and episode coordinates.
+ [JsonPropertyName("identity")]
+ public JmanifestIdentity? Identity { get; set; }
+
+ /// Gets or sets which encode the timings apply to.
+ [JsonPropertyName("cut")]
+ public JmanifestCut? Cut { get; set; }
+
+ /// Gets or sets extraction provenance.
+ [JsonPropertyName("extraction")]
+ public JmanifestExtraction? Extraction { get; set; }
+
+ /// Gets the actors and their presence windows.
+ [JsonPropertyName("actors")]
+ [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+ public Collection Actors { get; } = new();
+}
diff --git a/Jellyfin.Plugin.JRay/Models/JmanifestActor.cs b/Jellyfin.Plugin.JRay/Models/JmanifestActor.cs
new file mode 100644
index 0000000..0632e14
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/JmanifestActor.cs
@@ -0,0 +1,29 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+/// One actor's timeline within a manifest.
+public class JmanifestActor
+{
+ ///
+ /// 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.
+ ///
+ [JsonPropertyName("name")]
+ public string? Name { get; set; }
+
+ /// Gets or sets the IMDB person id.
+ [JsonPropertyName("imdb_id")]
+ public string? ImdbId { get; set; }
+
+ /// Gets or sets the TMDB person id — the primary join key.
+ [JsonPropertyName("tmdb_id")]
+ public string? TmdbId { get; set; }
+
+ /// Gets the presence windows.
+ [JsonPropertyName("scenes")]
+ [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+ public Collection Scenes { get; } = new();
+}
diff --git a/Jellyfin.Plugin.JRay/Models/JmanifestCut.cs b/Jellyfin.Plugin.JRay/Models/JmanifestCut.cs
new file mode 100644
index 0000000..ca39084
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/JmanifestCut.cs
@@ -0,0 +1,32 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+/// Cut fingerprint (server spec §2, §3).
+public class JmanifestCut
+{
+ /// Gets or sets the decoded duration the timings came from.
+ [JsonPropertyName("runtime_sec")]
+ public double RuntimeSec { get; set; }
+
+ /// Gets or sets the container duration, if it differs.
+ [JsonPropertyName("container_duration_sec")]
+ public double? ContainerDurationSec { get; set; }
+
+ ///
+ /// Gets or sets the OpenSubtitles file hash, as a server may report it.
+ ///
+ ///
+ /// Read-only in practice: the plugin never sends one. The file-hash
+ /// match tier is withdrawn on legal grounds — see
+ /// — because a file hash identifies the
+ /// exact release a user holds rather than the cut the timings describe.
+ ///
+ [JsonPropertyName("video_hash")]
+ public string? VideoHash { get; set; }
+
+ /// Gets or sets the version-prefixed spectral-peak signature.
+ [JsonPropertyName("audio_signature")]
+ public string? AudioSignature { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/JmanifestExtraction.cs b/Jellyfin.Plugin.JRay/Models/JmanifestExtraction.cs
new file mode 100644
index 0000000..3c76f97
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/JmanifestExtraction.cs
@@ -0,0 +1,31 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+/// Extraction provenance (server spec §2).
+public class JmanifestExtraction
+{
+ /// Gets or sets the sampling rate used during extraction.
+ [JsonPropertyName("sample_fps")]
+ public double? SampleFps { get; set; }
+
+ ///
+ /// Gets or sets the re-acquisition timeout that shapes window extent.
+ /// Successor to the withdrawn anneal_sec.
+ ///
+ [JsonPropertyName("extinction_sec")]
+ public double? ExtinctionSec { get; set; }
+
+ /// Gets or sets the producing pipeline's version string.
+ [JsonPropertyName("pipeline_version")]
+ public string? PipelineVersion { get; set; }
+
+ /// Gets or sets how many references the gallery held.
+ [JsonPropertyName("gallery_size")]
+ public int? GallerySize { get; set; }
+
+ /// Gets or sets global or limited.
+ [JsonPropertyName("gallery_scope")]
+ public string? GalleryScope { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/JmanifestIdentity.cs b/Jellyfin.Plugin.JRay/Models/JmanifestIdentity.cs
new file mode 100644
index 0000000..335b272
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/JmanifestIdentity.cs
@@ -0,0 +1,44 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+/// Title identity (server spec §2).
+public class JmanifestIdentity
+{
+ /// Gets or sets movie or episode.
+ [JsonPropertyName("type")]
+ public string Type { get; set; } = string.Empty;
+
+ /// Gets or sets the TMDB id, for a movie.
+ [JsonPropertyName("tmdb_id")]
+ public string? TmdbId { get; set; }
+
+ /// Gets or sets the IMDB id, for a movie.
+ [JsonPropertyName("imdb_id")]
+ public string? ImdbId { get; set; }
+
+ /// Gets or sets the series TMDB id, for an episode.
+ [JsonPropertyName("series_tmdb_id")]
+ public string? SeriesTmdbId { get; set; }
+
+ /// Gets or sets the series IMDB id, for an episode.
+ [JsonPropertyName("series_imdb_id")]
+ public string? SeriesImdbId { get; set; }
+
+ /// Gets or sets the season number, for an episode.
+ [JsonPropertyName("season")]
+ public int? Season { get; set; }
+
+ /// Gets or sets the episode number, for an episode.
+ [JsonPropertyName("episode")]
+ public int? Episode { get; set; }
+
+ /// Gets or sets the display title.
+ [JsonPropertyName("title")]
+ public string? Title { get; set; }
+
+ /// Gets or sets the release year.
+ [JsonPropertyName("year")]
+ public int? Year { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/JmanifestScene.cs b/Jellyfin.Plugin.JRay/Models/JmanifestScene.cs
new file mode 100644
index 0000000..8b09a9a
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/JmanifestScene.cs
@@ -0,0 +1,38 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// One presence window.
+///
+///
+/// A window is a claim about scene membership, not a recognition event
+/// (SR-002). An actor who turns away, is occluded, or is off-camera while the
+/// shot cuts to whoever they are speaking to is still present — so a consumer
+/// must never read a window boundary as "the face was detected here", and must
+/// not merge, split or trim windows.
+///
+public class JmanifestScene
+{
+ /// Gets or sets the window start, in seconds.
+ [JsonPropertyName("start")]
+ public double Start { get; set; }
+
+ /// Gets or sets the window end, in seconds, inclusive.
+ [JsonPropertyName("end")]
+ public double End { get; set; }
+
+ ///
+ /// Gets or sets the accumulated posterior that justified this claim, in [0, 1].
+ ///
+ [JsonPropertyName("belief")]
+ public double? Belief { get; set; }
+
+ ///
+ /// Gets or sets how the actor was identified: live, deferred
+ /// or pooled.
+ ///
+ [JsonPropertyName("route")]
+ public string? Route { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/ManifestFetchResponse.cs b/Jellyfin.Plugin.JRay/Models/ManifestFetchResponse.cs
new file mode 100644
index 0000000..a74948f
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/ManifestFetchResponse.cs
@@ -0,0 +1,31 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+/// The server's reply to a manifest fetch (server spec §4).
+public class ManifestFetchResponse
+{
+ ///
+ /// Gets or sets the cut-match tier that was achieved: exact,
+ /// audio, runtime or loose.
+ ///
+ [JsonPropertyName("match")]
+ public string Match { get; set; } = string.Empty;
+
+ ///
+ /// Gets or sets the offset, in seconds, the client must add to every window.
+ ///
+ ///
+ /// Non-zero only for an audio-tier match, where the same cut was
+ /// found at a different trim. The server returns the offset; the client
+ /// applies it — manifests are never rewritten, so one stored manifest
+ /// serves every trim of the same cut.
+ ///
+ [JsonPropertyName("offset_sec")]
+ public double OffsetSec { get; set; }
+
+ /// Gets or sets the manifest itself.
+ [JsonPropertyName("manifest")]
+ public Jmanifest? Manifest { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/ManifestFetchResult.cs b/Jellyfin.Plugin.JRay/Models/ManifestFetchResult.cs
new file mode 100644
index 0000000..c6f561f
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/ManifestFetchResult.cs
@@ -0,0 +1,30 @@
+namespace Jellyfin.Plugin.JRay.Models;
+
+///
+/// What a fetch stored, and from where.
+///
+public class ManifestFetchResult
+{
+ /// Gets or sets the server that supplied the manifest.
+ public string ServerUrl { get; set; } = string.Empty;
+
+ /// Gets or sets the cut-match tier achieved.
+ public string Match { get; set; } = string.Empty;
+
+ /// Gets or sets the offset applied to every window, in seconds.
+ public double OffsetSec { get; set; }
+
+ /// Gets or sets how many actors the stored truth file holds.
+ public int ActorCount { get; set; }
+
+ ///
+ /// Gets or sets a caveat to surface in the UI, or null when the match needs
+ /// no explanation.
+ ///
+ ///
+ /// A loose 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.
+ ///
+ public string? Caveat { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Models/SeriesBundle.cs b/Jellyfin.Plugin.JRay/Models/SeriesBundle.cs
new file mode 100644
index 0000000..6df1274
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/SeriesBundle.cs
@@ -0,0 +1,17 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+/// A series bundle (server spec §2).
+public class SeriesBundle
+{
+ /// Gets or sets the envelope version.
+ [JsonPropertyName("jmanifest_version")]
+ public int JmanifestVersion { get; set; }
+
+ /// Gets the episode manifests the server holds.
+ [JsonPropertyName("episodes")]
+ [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+ public Collection Episodes { get; } = new();
+}
diff --git a/Jellyfin.Plugin.JRay/Models/ServerCapabilities.cs b/Jellyfin.Plugin.JRay/Models/ServerCapabilities.cs
new file mode 100644
index 0000000..0f3a24f
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Models/ServerCapabilities.cs
@@ -0,0 +1,23 @@
+using System.Collections.ObjectModel;
+using System.Text.Json.Serialization;
+
+namespace Jellyfin.Plugin.JRay.Models;
+
+/// What a server says it supports (server spec §9a).
+public class ServerCapabilities
+{
+ /// Gets or sets the server's own identity.
+ [JsonPropertyName("server_id")]
+ public string? ServerId { get; set; }
+
+ ///
+ /// Gets the exchange envelope versions the server accepts.
+ ///
+ ///
+ /// Checked once rather than discovered as a rejection per manifest across a
+ /// whole library sweep.
+ ///
+ [JsonPropertyName("jmanifest_versions")]
+ [JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
+ public Collection JmanifestVersions { get; } = new();
+}
diff --git a/Jellyfin.Plugin.JRay/ServiceRegistrator.cs b/Jellyfin.Plugin.JRay/ServiceRegistrator.cs
index faaacb6..4fa64f5 100644
--- a/Jellyfin.Plugin.JRay/ServiceRegistrator.cs
+++ b/Jellyfin.Plugin.JRay/ServiceRegistrator.cs
@@ -17,5 +17,10 @@ public class ServiceRegistrator : IPluginServiceRegistrator
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
serviceCollection.AddSingleton();
+
+ // Singleton so per-server backoff state survives across requests: a
+ // server that is down should be skipped for the whole sweep, not
+ // retried once per item (JR-037).
+ serviceCollection.AddSingleton();
}
}
diff --git a/Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs b/Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs
new file mode 100644
index 0000000..61af446
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/Interfaces/IManifestExchangeClient.cs
@@ -0,0 +1,53 @@
+using System.Collections.Generic;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.JRay.Configuration;
+
+namespace Jellyfin.Plugin.JRay.Services.Interfaces;
+
+///
+/// Fetches actor-timeline manifests from the configured public servers.
+///
+///
+/// Satisfies JRay-public-server UR-007: the plugin queries a configurable,
+/// ordered list of servers, and the first result clearing the configured
+/// match tier wins.
+///
+// TRACES: JR-025 | PR-006
+public interface IManifestExchangeClient
+{
+ ///
+ /// Fetches a movie manifest from the first server that has an acceptable one.
+ ///
+ /// The configured servers, in trust order.
+ /// The lowest cut-match tier that may be stored.
+ /// Identity and cut parameters for the local item.
+ /// Cancellation token.
+ /// The accepted manifest, or null when no server had one.
+ Task FetchMovieAsync(
+ IReadOnlyList servers,
+ MatchTier minimumTier,
+ TitleQuery query,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Fetches a single episode manifest.
+ ///
+ /// The configured servers, in trust order.
+ /// The lowest cut-match tier that may be stored.
+ /// Identity and cut parameters for the local item.
+ /// Cancellation token.
+ /// The accepted manifest, or null when no server had one.
+ Task FetchEpisodeAsync(
+ IReadOnlyList servers,
+ MatchTier minimumTier,
+ TitleQuery query,
+ CancellationToken cancellationToken);
+
+ ///
+ /// Per-server reachability and last error, for the configuration page.
+ ///
+ /// The configured servers.
+ /// One status entry per configured server.
+ IReadOnlyList GetStatus(IReadOnlyList servers);
+}
diff --git a/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs b/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs
new file mode 100644
index 0000000..090b706
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs
@@ -0,0 +1,100 @@
+using System;
+using System.Globalization;
+using Jellyfin.Plugin.JRay.Configuration;
+using Jellyfin.Plugin.JRay.Models;
+
+namespace Jellyfin.Plugin.JRay.Services;
+
+///
+/// Converts a fetched manifest into the truth file the plugin stores.
+///
+// TRACES: JR-030 | SR-002, SR-003
+public static class ManifestConverter
+{
+ ///
+ /// Builds a truth file from a manifest, shifting every window by
+ /// .
+ ///
+ ///
+ /// The offset is applied here, once, at store time. The server returns
+ /// it and the client applies it, so a single stored manifest serves every
+ /// trim of the same cut without ever being rewritten upstream. Applying it on
+ /// the way in means the stored truth is always in the local file's own
+ /// timebase, so the overlay and the jray?t= query need no offset
+ /// awareness at read time — the alternative would put the same correction in
+ /// every reader, forever, and one of them would eventually forget.
+ ///
+ /// Windows are shifted, never reshaped: a window is a claim about scene
+ /// membership (SR-002), so merging or trimming would answer a different
+ /// question than the one the extraction pipeline answered.
+ ///
+ ///
+ /// The validated manifest.
+ /// Seconds to add to every window.
+ /// Local media path, recorded informationally.
+ /// The truth file to store.
+ public static TruthFile ToTruthFile(Jmanifest manifest, double offsetSec, string mediaPath)
+ {
+ ArgumentNullException.ThrowIfNull(manifest);
+
+ var truth = new TruthFile
+ {
+ SchemaVersion = 1,
+ Movie = mediaPath ?? string.Empty,
+ SampleFps = manifest.Extraction?.SampleFps ?? 0,
+ };
+
+ foreach (var actor in manifest.Actors)
+ {
+ var converted = new TruthActor
+ {
+ Name = actor.Name ?? string.Empty,
+ ImdbId = actor.ImdbId ?? string.Empty,
+ TmdbId = actor.TmdbId ?? string.Empty,
+ };
+
+ foreach (var scene in actor.Scenes)
+ {
+ // Clamped at zero: a negative offset on an early window would
+ // otherwise produce a start before the file begins, which no
+ // reader can index.
+ var start = Math.Max(0, scene.Start + offsetSec);
+ var end = Math.Max(start, scene.End + offsetSec);
+ converted.Scenes.Add(new[] { start, end });
+ }
+
+ truth.Actors.Add(converted);
+ }
+
+ return truth;
+ }
+
+ ///
+ /// A short, human-readable description of how a manifest matched, for the
+ /// UI to show as a caveat.
+ ///
+ ///
+ /// A loose match should surface as a caveat rather than being applied
+ /// silently: it means the runtimes differ by up to 30 seconds, which is
+ /// usually a different trim of the same cut but is not guaranteed to be.
+ ///
+ /// The tier achieved.
+ /// The offset applied.
+ /// A caveat string, or null when the match needs no explanation.
+ public static string? DescribeCaveat(MatchTier tier, double offsetSec)
+ {
+ if (tier == MatchTier.Loose)
+ {
+ return "Matched loosely — the runtime differs from this server's copy, so timings may drift.";
+ }
+
+ if (Math.Abs(offsetSec) > 0.001)
+ {
+ return string.Create(
+ CultureInfo.InvariantCulture,
+ $"Matched by audio content and shifted by {offsetSec:0.##}s to align with this file.");
+ }
+
+ return null;
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs b/Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs
new file mode 100644
index 0000000..7edd668
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/ManifestExchangeClient.cs
@@ -0,0 +1,447 @@
+using System;
+using System.Collections.Concurrent;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using System.Net.Http;
+using System.Text.Json;
+using System.Threading;
+using System.Threading.Tasks;
+using Jellyfin.Plugin.JRay.Configuration;
+using Jellyfin.Plugin.JRay.Models;
+using Jellyfin.Plugin.JRay.Services.Interfaces;
+using Microsoft.Extensions.Logging;
+
+namespace Jellyfin.Plugin.JRay.Services;
+
+///
+/// Fetches actor-timeline manifests from the configured servers.
+///
+///
+/// Servers are an ordered list, and order is the user's trust ranking made
+/// explicit: for a fetch, servers are tried in order and the first acceptable
+/// result wins — acceptable meaning it clears the configured match tier.
+///
+/// First-match rather than best-match is deliberate. Querying every server for
+/// every item multiplies egress, leaks the library to more parties, and the
+/// ordering already encodes which source the admin prefers. Each configured
+/// server multiplies the privacy exposure described in the server spec §9, so
+/// later servers are queried only for what earlier ones lacked.
+///
+///
+// TRACES: JR-025, JR-029, JR-030, JR-037 | PR-005, PR-006
+public class ManifestExchangeClient : IManifestExchangeClient, IDisposable
+{
+ /// Server spec §9: a single manifest response is capped at 2 MiB.
+ public const long MaxManifestBytes = 2 * 1024 * 1024;
+
+ /// Server spec §9: a bundle response is capped at 25 MiB.
+ public const long MaxBundleBytes = 25L * 1024 * 1024;
+
+ private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
+ private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(30);
+
+ ///
+ /// How long a server that failed is skipped for, doubling each consecutive
+ /// failure. One dead server must never stall a library sweep.
+ ///
+ private static readonly TimeSpan BaseBackoff = TimeSpan.FromMinutes(1);
+ private static readonly TimeSpan MaxBackoff = TimeSpan.FromHours(1);
+
+ private readonly HttpClient _http;
+ private readonly ILogger _logger;
+ private readonly ConcurrentDictionary _health = new(StringComparer.Ordinal);
+ private readonly bool _ownsClient;
+
+ ///
+ /// Initializes a new instance of the class.
+ ///
+ /// Logger.
+ public ManifestExchangeClient(ILogger logger)
+ : this(logger, null)
+ {
+ }
+
+ ///
+ /// Initializes a new instance of the class
+ /// with an injected transport, for testing.
+ ///
+ /// Logger.
+ /// Transport to use, or null to build the default.
+ public ManifestExchangeClient(ILogger logger, HttpClient? httpClient)
+ {
+ _logger = logger;
+ _ownsClient = httpClient is null;
+ _http = httpClient ?? new HttpClient(new SocketsHttpHandler
+ {
+ ConnectTimeout = ConnectTimeout,
+ // Certificate validation is never disabled: a plaintext or
+ // unverified server would let any network intermediary rewrite
+ // actor overlays.
+ AutomaticDecompression = System.Net.DecompressionMethods.All,
+ })
+ {
+ Timeout = ReadTimeout,
+ };
+ }
+
+ ///
+ public async Task FetchMovieAsync(
+ IReadOnlyList servers,
+ MatchTier minimumTier,
+ TitleQuery query,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(servers);
+ ArgumentNullException.ThrowIfNull(query);
+
+ foreach (var server in Eligible(servers))
+ {
+ var url = BuildUrl(server, "manifests/movie", query);
+ if (url is null)
+ {
+ continue;
+ }
+
+ var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
+ .ConfigureAwait(false);
+ if (outcome is not null)
+ {
+ // First acceptable result wins — no further servers are queried,
+ // which is what bounds the privacy exposure.
+ return outcome;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ public async Task FetchEpisodeAsync(
+ IReadOnlyList servers,
+ MatchTier minimumTier,
+ TitleQuery query,
+ CancellationToken cancellationToken)
+ {
+ ArgumentNullException.ThrowIfNull(servers);
+ ArgumentNullException.ThrowIfNull(query);
+
+ foreach (var server in Eligible(servers))
+ {
+ var url = BuildUrl(server, "manifests/episode", query);
+ if (url is null)
+ {
+ continue;
+ }
+
+ var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
+ .ConfigureAwait(false);
+ if (outcome is not null)
+ {
+ return outcome;
+ }
+ }
+
+ return null;
+ }
+
+ ///
+ public IReadOnlyList GetStatus(IReadOnlyList servers)
+ {
+ ArgumentNullException.ThrowIfNull(servers);
+
+ return servers.Select(s =>
+ {
+ _health.TryGetValue(s.Url, out var h);
+ return new ServerStatus
+ {
+ Url = s.Url,
+ Name = s.Name,
+ Enabled = s.Enabled,
+ Reachable = h is null || h.ConsecutiveFailures == 0,
+ LastError = h?.LastError,
+ SkippedUntil = h?.SkipUntil,
+ };
+ }).ToList();
+ }
+
+ ///
+ /// Servers that are enabled and not currently in backoff, in configured order.
+ ///
+ private IEnumerable Eligible(IReadOnlyList servers)
+ {
+ var now = DateTimeOffset.UtcNow;
+ foreach (var s in servers)
+ {
+ if (!s.Enabled || string.IsNullOrWhiteSpace(s.Url))
+ {
+ continue;
+ }
+
+ if (_health.TryGetValue(s.Url, out var h) && h.SkipUntil > now)
+ {
+ _logger.LogDebug("Skipping {Url} until {Until} after {Failures} failures", s.Url, h.SkipUntil, h.ConsecutiveFailures);
+ continue;
+ }
+
+ yield return s;
+ }
+ }
+
+ private async Task TryFetchOneAsync(
+ ManifestServer server,
+ Uri url,
+ TitleQuery query,
+ MatchTier minimumTier,
+ CancellationToken cancellationToken)
+ {
+ try
+ {
+ using var response = await _http
+ .GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
+ .ConfigureAwait(false);
+
+ if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
+ {
+ // Not an error: this server simply does not hold it. The next
+ // server in the list gets a turn.
+ RecordSuccess(server.Url);
+ return null;
+ }
+
+ if (!response.IsSuccessStatusCode)
+ {
+ RecordFailure(server.Url, $"HTTP {(int)response.StatusCode}");
+ return null;
+ }
+
+ var json = await ReadCappedAsync(response, MaxManifestBytes, cancellationToken)
+ .ConfigureAwait(false);
+ if (json is null)
+ {
+ RecordFailure(server.Url, "response exceeded the size cap");
+ return null;
+ }
+
+ var body = JsonSerializer.Deserialize(json);
+ RecordSuccess(server.Url);
+
+ if (body?.Manifest is null)
+ {
+ return null;
+ }
+
+ var tier = ParseTier(body.Match);
+ if (tier is null || tier < minimumTier)
+ {
+ _logger.LogDebug(
+ "{Url} matched at {Tier}, below the configured minimum {Minimum}",
+ server.Url,
+ body.Match,
+ minimumTier);
+ return null;
+ }
+
+ if (!ManifestValidator.TryValidate(body.Manifest, query.RuntimeSec, out var error))
+ {
+ // A manifest is never trusted merely because a server served it.
+ _logger.LogWarning("Rejected manifest from {Url}: {Error}", server.Url, error);
+ return null;
+ }
+
+ return new ManifestFetchOutcome
+ {
+ ServerUrl = server.Url,
+ Tier = tier.Value,
+ OffsetSec = body.OffsetSec,
+ Manifest = body.Manifest,
+ };
+ }
+ catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
+ {
+ throw;
+ }
+ catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
+ {
+ // A slow, unreachable or nonsense-returning server is skipped and
+ // backed off; it must never stall the sweep or fail the whole fetch.
+ RecordFailure(server.Url, ex.Message);
+ return null;
+ }
+ }
+
+ ///
+ /// Reads a response body, aborting once it exceeds .
+ ///
+ ///
+ /// Capped while streaming rather than after buffering: a hostile
+ /// server can declare any Content-Length it likes, so reading to
+ /// completion and then measuring is exactly the denial-of-service primitive
+ /// the cap exists to prevent.
+ ///
+ private static async Task ReadCappedAsync(
+ HttpResponseMessage response,
+ long cap,
+ CancellationToken cancellationToken)
+ {
+ // The declared length is a cheap early rejection, never the enforcement.
+ if (response.Content.Headers.ContentLength is { } declared && declared > cap)
+ {
+ return null;
+ }
+
+ using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
+ var buffer = new byte[8192];
+ using var accumulated = new System.IO.MemoryStream();
+
+ while (true)
+ {
+ var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
+ if (read == 0)
+ {
+ break;
+ }
+
+ if (accumulated.Length + read > cap)
+ {
+ return null;
+ }
+
+ await accumulated.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
+ }
+
+ return System.Text.Encoding.UTF8.GetString(accumulated.ToArray());
+ }
+
+ ///
+ /// Builds a fetch URL, or null when the server's URL is unusable.
+ ///
+ ///
+ /// HTTPS is required for anything that is not loopback. A plaintext
+ /// community server would let any network intermediary rewrite actor
+ /// overlays, and the overlay is displayed to the user as fact.
+ ///
+ /// The configured server.
+ /// API path below /api/v1/.
+ /// Identity and cut parameters.
+ /// The URL to request, or null when the server URL is unusable.
+ internal static Uri? BuildUrl(ManifestServer server, string path, TitleQuery query)
+ {
+ ArgumentNullException.ThrowIfNull(server);
+ ArgumentNullException.ThrowIfNull(query);
+
+ if (!Uri.TryCreate(server.Url, UriKind.Absolute, out var baseUri))
+ {
+ return null;
+ }
+
+ if (!IsTransportAcceptable(baseUri))
+ {
+ return null;
+ }
+
+ var q = query.ToQueryString();
+ var trimmed = baseUri.AbsoluteUri.TrimEnd('/');
+ return Uri.TryCreate($"{trimmed}/api/v1/{path}?{q}", UriKind.Absolute, out var built)
+ ? built
+ : null;
+ }
+
+ ///
+ /// True when the URL may be used: HTTPS anywhere, or HTTP on loopback only.
+ ///
+ /// The server base URL.
+ /// true when the transport is acceptable.
+ internal static bool IsTransportAcceptable(Uri uri)
+ {
+ ArgumentNullException.ThrowIfNull(uri);
+
+ if (uri.Scheme == Uri.UriSchemeHttps)
+ {
+ return true;
+ }
+
+ if (uri.Scheme != Uri.UriSchemeHttp)
+ {
+ return false;
+ }
+
+ // Loopback is exempt because there is no network path to intercept.
+ return uri.IsLoopback;
+ }
+
+ /// Parses a tier name the server reported.
+ /// The tier string.
+ /// The tier, or null when unrecognised.
+ internal static MatchTier? ParseTier(string? tier) => tier switch
+ {
+ // `exact` is deliberately absent: the file-hash tier is withdrawn on
+ // legal grounds (see MatchTier). A server cannot report it to us anyway,
+ // since we send no `video_hash` — and if one did, treating it as
+ // unrecognised means the manifest is declined rather than silently
+ // accepted under a tier this plugin has no policy for.
+ "audio" => MatchTier.Audio,
+ "runtime" => MatchTier.Runtime,
+ "loose" => MatchTier.Loose,
+ _ => null,
+ };
+
+ private void RecordSuccess(string url) => _health.TryRemove(url, out _);
+
+ private void RecordFailure(string url, string error)
+ {
+ var updated = _health.AddOrUpdate(
+ url,
+ _ => new ServerHealth { ConsecutiveFailures = 1, LastError = error, SkipUntil = DateTimeOffset.UtcNow + BaseBackoff },
+ (_, existing) =>
+ {
+ var failures = existing.ConsecutiveFailures + 1;
+ // Exponential, capped: a server that is down for a day should
+ // not be retried every minute for that whole day.
+ var delayTicks = Math.Min(
+ BaseBackoff.Ticks * (long)Math.Pow(2, Math.Min(failures - 1, 6)),
+ MaxBackoff.Ticks);
+ return new ServerHealth
+ {
+ ConsecutiveFailures = failures,
+ LastError = error,
+ SkipUntil = DateTimeOffset.UtcNow + TimeSpan.FromTicks(delayTicks),
+ };
+ });
+
+ _logger.LogWarning(
+ "Server {Url} failed ({Failures} consecutive): {Error}. Skipping until {Until}",
+ url,
+ updated.ConsecutiveFailures,
+ error,
+ updated.SkipUntil);
+ }
+
+ ///
+ public void Dispose()
+ {
+ Dispose(true);
+ GC.SuppressFinalize(this);
+ }
+
+ ///
+ /// Releases the transport when this instance created it.
+ ///
+ /// Whether managed resources should be released.
+ protected virtual void Dispose(bool disposing)
+ {
+ if (disposing && _ownsClient)
+ {
+ _http.Dispose();
+ }
+ }
+
+ private sealed class ServerHealth
+ {
+ public int ConsecutiveFailures { get; init; }
+
+ public string? LastError { get; init; }
+
+ public DateTimeOffset SkipUntil { get; init; }
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Services/ManifestFetchOutcome.cs b/Jellyfin.Plugin.JRay/Services/ManifestFetchOutcome.cs
new file mode 100644
index 0000000..56dd006
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/ManifestFetchOutcome.cs
@@ -0,0 +1,23 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Jellyfin.Plugin.JRay.Configuration;
+using Jellyfin.Plugin.JRay.Models;
+
+namespace Jellyfin.Plugin.JRay.Services;
+
+/// A manifest accepted from a server.
+public class ManifestFetchOutcome
+{
+ /// Gets or sets which server supplied it.
+ public string ServerUrl { get; set; } = string.Empty;
+
+ /// Gets or sets the cut-match tier achieved.
+ public MatchTier Tier { get; set; }
+
+ /// Gets or sets the offset the client must apply to every window.
+ public double OffsetSec { get; set; }
+
+ /// Gets or sets the manifest.
+ public Jmanifest? Manifest { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Services/ManifestValidator.cs b/Jellyfin.Plugin.JRay/Services/ManifestValidator.cs
new file mode 100644
index 0000000..3b9d82c
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/ManifestValidator.cs
@@ -0,0 +1,256 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using System.Linq;
+using Jellyfin.Plugin.JRay.Models;
+
+namespace Jellyfin.Plugin.JRay.Services;
+
+///
+/// Re-validates a manifest received from a server.
+///
+///
+/// Every server is untrusted, including the pre-configured community one.
+/// Everything the server specification guarantees is a property of a *correctly
+/// operated* server; pointing the plugin at an arbitrary URL inherits none of
+/// it. So the plugin re-applies client-side what the server applies on upload:
+/// unknown-shaped data rejected, identifiers format-checked, windows
+/// bounds-checked against the item's real runtime.
+///
+/// The honest framing for the configuration page is that adding a third-party
+/// server means trusting its operator not to serve you deliberately wrong actor
+/// data. These checks bound the damage to bad overlay content; they cannot make
+/// wrong data right.
+///
+///
+// TRACES: JR-027 | SR-004
+public static class ManifestValidator
+{
+ /// The exchange envelope version this plugin speaks (SR-003).
+ public const int SupportedJmanifestVersion = 2;
+
+ /// Server spec §6: no more than 500 actors in one manifest.
+ public const int MaxActors = 500;
+
+ /// Server spec §6: no more than 2000 windows for one actor.
+ public const int MaxScenesPerActor = 2000;
+
+ /// Server spec §6: no more than 20000 windows in total.
+ public const int MaxTotalScenes = 20000;
+
+ /// Server spec §6: names are capped at 200 characters.
+ public const int MaxNameLength = 200;
+
+ ///
+ /// Windows may exceed the measured runtime by this much before being
+ /// rejected, covering rounding and container-duration disagreement.
+ ///
+ public const double RuntimeToleranceSec = 5.0;
+
+ ///
+ /// Validates a manifest against the local item's measured runtime.
+ ///
+ /// The manifest as received.
+ ///
+ /// The runtime of the local file, or null when it is not known. Windows are
+ /// bounds-checked against it when it is available.
+ ///
+ /// The first problem found, naming the offending field.
+ /// true when the manifest is safe to store.
+ public static bool TryValidate(Jmanifest? manifest, double? localRuntimeSec, out string error)
+ {
+ if (manifest is null)
+ {
+ error = "manifest: absent";
+ return false;
+ }
+
+ // An unknown envelope version is refused, never guessed at (JR-003).
+ // A server one version ahead may have changed the meaning of a field
+ // this plugin thinks it understands.
+ if (manifest.JmanifestVersion != SupportedJmanifestVersion)
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"jmanifest_version: unsupported version {manifest.JmanifestVersion}, expected {SupportedJmanifestVersion}");
+ return false;
+ }
+
+ if (manifest.Identity is null)
+ {
+ error = "identity: absent";
+ return false;
+ }
+
+ if (manifest.Cut is null || !IsSaneRuntime(manifest.Cut.RuntimeSec))
+ {
+ error = "cut.runtime_sec: absent or not a plausible duration";
+ return false;
+ }
+
+ if (manifest.Actors.Count == 0)
+ {
+ error = "actors: empty";
+ return false;
+ }
+
+ if (manifest.Actors.Count > MaxActors)
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors: more than {MaxActors} entries");
+ return false;
+ }
+
+ // Bounds are checked against the *local* file where known, because that
+ // is what the overlay will index into. A window past the end of the file
+ // is not merely useless, it is evidence the manifest is for another cut.
+ var limit = (localRuntimeSec ?? manifest.Cut.RuntimeSec) + RuntimeToleranceSec;
+
+ var total = 0;
+ var seenTmdb = new HashSet(StringComparer.Ordinal);
+
+ for (var i = 0; i < manifest.Actors.Count; i++)
+ {
+ var actor = manifest.Actors[i];
+
+ if (actor.Name is { Length: > MaxNameLength })
+ {
+ error = string.Create(CultureInfo.InvariantCulture, $"actors[{i}].name: too long");
+ return false;
+ }
+
+ if (actor.Name is not null && ContainsControlCharacters(actor.Name))
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].name: contains control characters");
+ return false;
+ }
+
+ if (actor.TmdbId is { Length: > 0 } tmdb)
+ {
+ if (!IsDigits(tmdb, 9))
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].tmdb_id: not a TMDB id");
+ return false;
+ }
+
+ if (!seenTmdb.Add(tmdb))
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].tmdb_id: duplicate actor");
+ return false;
+ }
+ }
+
+ if (actor.ImdbId is { Length: > 0 } imdb && !IsPersonImdbId(imdb))
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].imdb_id: not an IMDB person id");
+ return false;
+ }
+
+ if (actor.Scenes.Count > MaxScenesPerActor)
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].scenes: more than {MaxScenesPerActor} entries");
+ return false;
+ }
+
+ total += actor.Scenes.Count;
+ if (total > MaxTotalScenes)
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors: more than {MaxTotalScenes} windows in total");
+ return false;
+ }
+
+ for (var j = 0; j < actor.Scenes.Count; j++)
+ {
+ var scene = actor.Scenes[j];
+ if (!IsFinite(scene.Start) || !IsFinite(scene.End))
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].scenes[{j}]: non-finite value");
+ return false;
+ }
+
+ if (scene.Start < 0 || scene.End < scene.Start)
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].scenes[{j}]: negative or inverted window");
+ return false;
+ }
+
+ if (scene.End > limit)
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].scenes[{j}]: ends beyond the item's runtime");
+ return false;
+ }
+
+ // A posterior outside [0, 1] is not a probability.
+ if (scene.Belief is { } b && (!IsFinite(b) || b < 0 || b > 1))
+ {
+ error = string.Create(
+ CultureInfo.InvariantCulture,
+ $"actors[{i}].scenes[{j}].belief: outside [0, 1]");
+ return false;
+ }
+ }
+ }
+
+ error = string.Empty;
+ return true;
+ }
+
+ private static bool IsSaneRuntime(double v) => IsFinite(v) && v > 0 && v < 200_000;
+
+ private static bool IsFinite(double v) => !double.IsNaN(v) && !double.IsInfinity(v);
+
+ private static bool IsDigits(string s, int maxLength) =>
+ s.Length > 0 && s.Length <= maxLength && s.All(char.IsAsciiDigit);
+
+ private static bool IsPersonImdbId(string s) =>
+ s.StartsWith("nm", StringComparison.Ordinal)
+ && (s.Length == 9 || s.Length == 10)
+ && s.AsSpan(2).ToString().All(char.IsAsciiDigit);
+
+ ///
+ /// Control characters are refused outright. The overlay renders names as
+ /// text nodes (JR-024), so markup is already inert, but a bidi override or a
+ /// zero-width joiner can still make a name display as something other than
+ /// what was stored.
+ ///
+ private static bool ContainsControlCharacters(string s)
+ {
+ foreach (var c in s)
+ {
+ if (char.IsControl(c))
+ {
+ return true;
+ }
+
+ // Zero-width and bidi-control codepoints.
+ if (c is >= '' and <= ''
+ or >= '' and <= ''
+ or >= '' and <= ''
+ or '')
+ {
+ return true;
+ }
+ }
+
+ return false;
+ }
+}
diff --git a/Jellyfin.Plugin.JRay/Services/ServerStatus.cs b/Jellyfin.Plugin.JRay/Services/ServerStatus.cs
new file mode 100644
index 0000000..68aca1d
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/ServerStatus.cs
@@ -0,0 +1,29 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Jellyfin.Plugin.JRay.Configuration;
+using Jellyfin.Plugin.JRay.Models;
+
+namespace Jellyfin.Plugin.JRay.Services;
+
+/// Per-server reachability, for the configuration page.
+public class ServerStatus
+{
+ /// Gets or sets the server's base URL.
+ public string Url { get; set; } = string.Empty;
+
+ /// Gets or sets the display name.
+ public string Name { get; set; } = string.Empty;
+
+ /// Gets or sets a value indicating whether the server is enabled.
+ public bool Enabled { get; set; }
+
+ /// Gets or sets a value indicating whether the last attempt succeeded.
+ public bool Reachable { get; set; }
+
+ /// Gets or sets the last error seen, if any.
+ public string? LastError { get; set; }
+
+ /// Gets or sets when this server will next be tried.
+ public DateTimeOffset? SkippedUntil { get; set; }
+}
diff --git a/Jellyfin.Plugin.JRay/Services/TitleQuery.cs b/Jellyfin.Plugin.JRay/Services/TitleQuery.cs
new file mode 100644
index 0000000..fa79653
--- /dev/null
+++ b/Jellyfin.Plugin.JRay/Services/TitleQuery.cs
@@ -0,0 +1,57 @@
+using System;
+using System.Collections.Generic;
+using System.Globalization;
+using Jellyfin.Plugin.JRay.Configuration;
+using Jellyfin.Plugin.JRay.Models;
+
+namespace Jellyfin.Plugin.JRay.Services;
+
+/// Identity and cut parameters for a fetch.
+public class TitleQuery
+{
+ /// Gets or sets the movie's TMDB id.
+ public string? TmdbId { get; set; }
+
+ /// Gets or sets the movie's IMDB id.
+ public string? ImdbId { get; set; }
+
+ /// Gets or sets the series TMDB id, for an episode.
+ public string? SeriesTmdbId { get; set; }
+
+ /// Gets or sets the season number, for an episode.
+ public int? Season { get; set; }
+
+ /// Gets or sets the episode number, for an episode.
+ public int? Episode { get; set; }
+
+ /// Gets or sets the local file's measured runtime, in seconds.
+ public double? RuntimeSec { get; set; }
+
+ /// Renders the query parameters the server expects.
+ ///
+ /// There is deliberately no video_hash parameter. The file-hash tier
+ /// is withdrawn on legal grounds (see ), and omitting
+ /// the field here is what makes that structural: there is nothing to send,
+ /// so no future caller can start sending one by setting a property.
+ ///
+ /// An escaped query string, without the leading '?'.
+ public string ToQueryString()
+ {
+ var parts = new List();
+ void Add(string key, string? value)
+ {
+ if (!string.IsNullOrEmpty(value))
+ {
+ parts.Add($"{key}={Uri.EscapeDataString(value)}");
+ }
+ }
+
+ Add("tmdb_id", TmdbId);
+ Add("imdb_id", ImdbId);
+ Add("series_tmdb_id", SeriesTmdbId);
+ Add("season", Season?.ToString(CultureInfo.InvariantCulture));
+ Add("episode", Episode?.ToString(CultureInfo.InvariantCulture));
+ Add("runtime_sec", RuntimeSec?.ToString("0.###", CultureInfo.InvariantCulture));
+ return string.Join('&', parts);
+ }
+}
diff --git a/SPEC.md b/SPEC.md
index c77d9f6..4edec26 100644
--- a/SPEC.md
+++ b/SPEC.md
@@ -625,14 +625,37 @@ a rejected upload would otherwise push tens of MiB pointlessly.
### JR-036 — Match tier is the user's dial
-The configured minimum tier (`exact` / `audio` / `runtime` / `loose`) gates what
-may be stored. A `loose` match — runtimes within ±30 s — is plausibly a different
-trim of the same cut, so it is **surfaced as a caveat in the UI**, not applied
+The configured minimum tier (`audio` / `runtime` / `loose`) gates what may be
+stored. A `loose` match — runtimes within ±30 s — is plausibly a different trim
+of the same cut, so it is **surfaced as a caveat in the UI**, not applied
silently. Per JR-010 the tier is recorded with the stored truth, which is what
makes surfacing it possible after the fetch has finished.
-**Current:** `MinimumMatchTier` exists in configuration, defaulting to `runtime`.
-**Gap:** nothing reads it; no caveat is displayed.
+**There is no `exact` tier here, and the plugin sends no `video_hash`.** The
+server spec §3 defines `exact` as an equal OpenSubtitles file hash, and it is the
+strongest *technical* signal available — it identifies a specific file, so it
+cannot produce a false positive. That is exactly why it is withdrawn.
+
+A TMDB id discloses "some copy of this film", which is what a library catalogue
+discloses. A file hash discloses **this exact release**, which turns a catalogue
+lookup into a release-identification service and turns a server's database into a
+mapping from file fingerprints to the instances holding them. That is a far more
+specific disclosure than PR-005 permits, and a dataset no volunteer operator
+should be asked to hold.
+
+The audio signature is the deliberate replacement: derived from *content*, it
+identifies the **cut** rather than the copy, so two different encodes of the same
+edit agree. It answers the question the exchange needs — "do these timings apply
+to this media?" — without answering the one it must not. `audio` is therefore the
+top tier.
+
+A server may still hold hashes contributed by other clients; this plugin simply
+never participates, and `MatchTier` has no `Exact` member so no code path can
+come to depend on one.
+
+**Current:** `MinimumMatchTier` exists in configuration, defaulting to `runtime`,
+and `ManifestExchangeClient` rejects a below-tier match. **Gap:** the caveat is
+returned by the fetch endpoint but not yet displayed in the overlay.
---
@@ -696,8 +719,8 @@ test rather than an aspiration. Extraction's counterpart is `IR-005`.
**JR-044 — media shorter than 120 s.** The window underflows, so **no signature
is emitted and no sync offset is applied**. Such items fall back to the runtime
-and exact tiers, which is adequate: a 90-second extra is not content whose cut
-alignment matters. Both producers must apply the identical rule, or they diverge
+tier, which is adequate: a 90-second extra is not content whose cut alignment
+matters. Both producers must apply the identical rule, or they diverge
on exactly the short items most likely to be misidentified. Extraction's
counterpart is `IR-007`.
diff --git a/docs/requirements.md b/docs/requirements.md
index 21cdf95..f6735b1 100644
--- a/docs/requirements.md
+++ b/docs/requirements.md
@@ -119,19 +119,19 @@ stay `In Progress` until `JR-025` is `Done`.
| ID | Requirement | Traces to | Priority | Status |
|---|---|---|---|---|
-| JR-025 | Query an **ordered list** of servers; first result clearing the configured tier wins — **satisfies `JRay-public-server` UR-007** | PR-006 | High | In Progress |
+| JR-025 | Query an **ordered list** of servers; first result clearing the configured tier wins — **satisfies `JRay-public-server` UR-007** | PR-006 | High | Done |
| JR-026 | For a series, first-match applies per **episode** — later servers are queried only for the episodes earlier ones lacked | PR-006 | Medium | Planned |
-| JR-027 | Treat **every** server as untrusted, including the default: re-validate on receipt against the strict upload schema, bounds-check windows against the item's real runtime | SR-004 | High | Planned |
-| JR-028 | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB bundle — aborting rather than buffering | SR-004 | High | Planned |
-| JR-029 | HTTPS required for non-loopback servers; certificate validation must not be disabled | SR-004 | High | Planned |
-| JR-030 | Apply an `audio`-tier `offset` to **every** window before storing — stored truth is always in the local file's timebase, so read paths need no offset awareness | SR-003 | High | Planned |
+| JR-027 | Treat **every** server as untrusted, including the default: re-validate on receipt against the strict upload schema, bounds-check windows against the item's real runtime | SR-004 | High | Done |
+| JR-028 | Enforce response size caps **while streaming** — 2 MiB single, 25 MiB bundle — aborting rather than buffering | SR-004 | High | Done |
+| JR-029 | HTTPS required for non-loopback servers; certificate validation must not be disabled | SR-004 | High | Done |
+| JR-030 | Apply an `audio`-tier `offset` to **every** window before storing — stored truth is always in the local file's timebase, so read paths need no offset awareness | SR-003 | High | Done |
| JR-031 | Fetch endpoints: item fetch, series bundle fetch, per-server status, content identify | PR-006 | High | Planned |
| JR-032 | Identify is **never automatic** — storing a candidate is a separate confirmation step | PR-006 | Medium | Planned |
| JR-033 | Scheduled sweep over items lacking truth data, using the **batch** `exists` endpoint | PR-006 | Medium | Planned |
| JR-034 | Contribution strips `movie` and `jellyfin_id`, attaches identity from `ProviderIds` plus measured runtime, and posts **only** to contribute-enabled servers — never fanned out | PR-005 | High | Planned |
| JR-035 | Uploads set `Expect: 100-continue`, so a rejection lands before a bundle body is transmitted | PR-006 | Low | Planned |
| JR-036 | Minimum accepted match tier is configurable; a `loose` match surfaces as a caveat rather than being applied silently | PR-006 | Medium | In Progress |
-| JR-037 | A server that is unreachable or failing is skipped on a short timeout with backoff; one dead server never stalls a sweep | PR-006 | Medium | Planned |
+| JR-037 | A server that is unreachable or failing is skipped on a short timeout with backoff; one dead server never stalls a sweep | PR-006 | Medium | Done |
## Egress and privacy (JR-038 … JR-041)
@@ -267,7 +267,7 @@ framework reference and leans on `RollForward` to reach the 10.0 runtime.
| JR-033 | T1 | Sweep batches through `exists` and paces | Backlog smaller than one batch |
| JR-034 | **T1** | `movie` and `jellyfin_id` absent from the upload body | Contribution attempted to a `FetchOnly` server must not send |
| JR-035 | T1 | `Expect: 100-continue` set on uploads | — |
-| JR-036 | T1 | Below-tier match is not stored; `loose` is flagged | Tier configured to `exact` with only a `runtime` match available |
+| JR-036 | T1 | Below-tier match is not stored; `loose` is flagged | Tier configured to `audio` with only a `runtime` match available; **`MatchTier` has no `Exact` member** — the file-hash tier is withdrawn on legal grounds, so a test naming it would not compile |
| JR-037 | T1 | Failing server skipped, backoff grows | Every server failing must not hang the sweep |
| JR-038 | **T1** | Every exchange switch defaults off; community server disabled | Fresh config object, no user input |
| JR-039 | T1 | Batch never exceeds 100 items | Library of 10⁴ items produces a paced sweep |