Manifest fetch across the configured servers (JR-025 … JR-037)
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:
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Tests for the manifest exchange: tier policy, transport rules, validation on
|
||||
/// receipt, and offset application.
|
||||
/// </summary>
|
||||
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<MatchTier>();
|
||||
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<MatchTier>().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));
|
||||
}
|
||||
}
|
||||
@@ -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".
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Every tier is a claim about a <em>cut</em>, never about a copy. There is no
|
||||
/// file-level tier, and the plugin sends no <c>video_hash</c>.
|
||||
/// <para>
|
||||
/// <b>The <c>Exact</c> tier was withdrawn for legal reasons; do not re-add it
|
||||
/// without an explicit, recorded agreement.</b> 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 "<em>this exact release</em>", 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.
|
||||
/// </para>
|
||||
/// <para>
|
||||
/// The audio signature is the deliberate replacement: derived from content, it
|
||||
/// identifies the <em>cut</em>, 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. <c>Audio</c> is therefore
|
||||
/// the top tier here.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
public enum MatchTier
|
||||
{
|
||||
/// <summary>Audio 0.60–0.85, or runtimes within ±30s. Surfaced as a caveat in the UI.</summary>
|
||||
@@ -31,11 +53,8 @@ public enum MatchTier
|
||||
/// <summary>Runtimes within ±2s.</summary>
|
||||
Runtime = 1,
|
||||
|
||||
/// <summary>Audio signature score ≥ 0.85; may carry a non-zero offset.</summary>
|
||||
/// <summary>Audio signature score ≥ 0.85; may carry a non-zero offset. The top tier.</summary>
|
||||
Audio = 2,
|
||||
|
||||
/// <summary>Identical <c>video_hash</c> — the same file.</summary>
|
||||
Exact = 3,
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches actor-timeline manifests from the configured public servers.
|
||||
/// </summary>
|
||||
// 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<ManifestController> _logger;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">Library manager.</param>
|
||||
/// <param name="exchange">Manifest exchange client.</param>
|
||||
/// <param name="truthStore">Managed truth store.</param>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ManifestController(
|
||||
ILibraryManager libraryManager,
|
||||
IManifestExchangeClient exchange,
|
||||
IManagedTruthStore truthStore,
|
||||
ILogger<ManifestController> logger)
|
||||
{
|
||||
_libraryManager = libraryManager;
|
||||
_exchange = exchange;
|
||||
_truthStore = truthStore;
|
||||
_logger = logger;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves an item across the configured servers and stores the first
|
||||
/// acceptable manifest.
|
||||
/// </summary>
|
||||
/// <param name="itemId">The library item id.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>What was fetched, and from where.</returns>
|
||||
/// <response code="200">A manifest was stored.</response>
|
||||
/// <response code="404">The item does not exist, or no server had a manifest for it.</response>
|
||||
/// <response code="409">Manifest sharing is disabled in the plugin configuration.</response>
|
||||
[HttpPost("Items/{itemId}/Fetch")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
[ProducesResponseType(StatusCodes.Status404NotFound)]
|
||||
[ProducesResponseType(StatusCodes.Status409Conflict)]
|
||||
public async Task<ActionResult<ManifestFetchResult>> 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),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Per-server reachability and last error, for the configuration page.
|
||||
/// </summary>
|
||||
/// <returns>One entry per configured server, in configured order.</returns>
|
||||
/// <response code="200">Status for each configured server.</response>
|
||||
[HttpGet("Servers/Status")]
|
||||
[ProducesResponseType(StatusCodes.Status200OK)]
|
||||
public ActionResult<IReadOnlyList<ServerStatus>> GetServerStatus()
|
||||
{
|
||||
var config = Plugin.Instance?.Configuration;
|
||||
if (config is null)
|
||||
{
|
||||
return Ok(Array.Empty<ServerStatus>());
|
||||
}
|
||||
|
||||
return Ok(_exchange.GetStatus(config.Servers.ToList()));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a provider id from an item, or null when it is absent.
|
||||
/// </summary>
|
||||
private static string? ProviderId(BaseItem? item, string provider) =>
|
||||
item?.ProviderIds is { } ids && ids.TryGetValue(provider, out var value)
|
||||
&& !string.IsNullOrWhiteSpace(value)
|
||||
? value
|
||||
: null;
|
||||
|
||||
/// <summary>
|
||||
/// Builds the lookup query from an item's provider ids and measured runtime.
|
||||
/// </summary>
|
||||
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 };
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -17,5 +17,10 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
|
||||
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
|
||||
serviceCollection.AddSingleton<IMediaPolicyStore, MediaPolicyStore>();
|
||||
|
||||
// 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<IManifestExchangeClient, ManifestExchangeClient>();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches actor-timeline manifests from the configured public servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Satisfies <c>JRay-public-server</c> UR-007: the plugin queries a configurable,
|
||||
/// <b>ordered</b> list of servers, and the first result clearing the configured
|
||||
/// match tier wins.
|
||||
/// </remarks>
|
||||
// TRACES: JR-025 | PR-006
|
||||
public interface IManifestExchangeClient
|
||||
{
|
||||
/// <summary>
|
||||
/// Fetches a movie manifest from the first server that has an acceptable one.
|
||||
/// </summary>
|
||||
/// <param name="servers">The configured servers, in trust order.</param>
|
||||
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
|
||||
/// <param name="query">Identity and cut parameters for the local item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The accepted manifest, or null when no server had one.</returns>
|
||||
Task<ManifestFetchOutcome?> FetchMovieAsync(
|
||||
IReadOnlyList<ManifestServer> servers,
|
||||
MatchTier minimumTier,
|
||||
TitleQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Fetches a single episode manifest.
|
||||
/// </summary>
|
||||
/// <param name="servers">The configured servers, in trust order.</param>
|
||||
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
|
||||
/// <param name="query">Identity and cut parameters for the local item.</param>
|
||||
/// <param name="cancellationToken">Cancellation token.</param>
|
||||
/// <returns>The accepted manifest, or null when no server had one.</returns>
|
||||
Task<ManifestFetchOutcome?> FetchEpisodeAsync(
|
||||
IReadOnlyList<ManifestServer> servers,
|
||||
MatchTier minimumTier,
|
||||
TitleQuery query,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
/// <summary>
|
||||
/// Per-server reachability and last error, for the configuration page.
|
||||
/// </summary>
|
||||
/// <param name="servers">The configured servers.</param>
|
||||
/// <returns>One status entry per configured server.</returns>
|
||||
IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> servers);
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
using System;
|
||||
using System.Globalization;
|
||||
using Jellyfin.Plugin.JRay.Configuration;
|
||||
using Jellyfin.Plugin.JRay.Models;
|
||||
|
||||
namespace Jellyfin.Plugin.JRay.Services;
|
||||
|
||||
/// <summary>
|
||||
/// Converts a fetched manifest into the truth file the plugin stores.
|
||||
/// </summary>
|
||||
// TRACES: JR-030 | SR-002, SR-003
|
||||
public static class ManifestConverter
|
||||
{
|
||||
/// <summary>
|
||||
/// Builds a truth file from a manifest, shifting every window by
|
||||
/// <paramref name="offsetSec"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>The offset is applied here, once, at store time.</b> 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 <c>jray?t=</c> 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
/// <param name="manifest">The validated manifest.</param>
|
||||
/// <param name="offsetSec">Seconds to add to every window.</param>
|
||||
/// <param name="mediaPath">Local media path, recorded informationally.</param>
|
||||
/// <returns>The truth file to store.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A short, human-readable description of how a manifest matched, for the
|
||||
/// UI to show as a caveat.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// A <c>loose</c> 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.
|
||||
/// </remarks>
|
||||
/// <param name="tier">The tier achieved.</param>
|
||||
/// <param name="offsetSec">The offset applied.</param>
|
||||
/// <returns>A caveat string, or null when the match needs no explanation.</returns>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Fetches actor-timeline manifests from the configured servers.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Servers are an <b>ordered list</b>, and order is the user's trust ranking made
|
||||
/// explicit: for a fetch, servers are tried in order and the <i>first acceptable</i>
|
||||
/// result wins — acceptable meaning it clears the configured match tier.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-025, JR-029, JR-030, JR-037 | PR-005, PR-006
|
||||
public class ManifestExchangeClient : IManifestExchangeClient, IDisposable
|
||||
{
|
||||
/// <summary>Server spec §9: a single manifest response is capped at 2 MiB.</summary>
|
||||
public const long MaxManifestBytes = 2 * 1024 * 1024;
|
||||
|
||||
/// <summary>Server spec §9: a bundle response is capped at 25 MiB.</summary>
|
||||
public const long MaxBundleBytes = 25L * 1024 * 1024;
|
||||
|
||||
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
|
||||
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
/// <summary>
|
||||
/// How long a server that failed is skipped for, doubling each consecutive
|
||||
/// failure. One dead server must never stall a library sweep.
|
||||
/// </summary>
|
||||
private static readonly TimeSpan BaseBackoff = TimeSpan.FromMinutes(1);
|
||||
private static readonly TimeSpan MaxBackoff = TimeSpan.FromHours(1);
|
||||
|
||||
private readonly HttpClient _http;
|
||||
private readonly ILogger<ManifestExchangeClient> _logger;
|
||||
private readonly ConcurrentDictionary<string, ServerHealth> _health = new(StringComparer.Ordinal);
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
public ManifestExchangeClient(ILogger<ManifestExchangeClient> logger)
|
||||
: this(logger, null)
|
||||
{
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class
|
||||
/// with an injected transport, for testing.
|
||||
/// </summary>
|
||||
/// <param name="logger">Logger.</param>
|
||||
/// <param name="httpClient">Transport to use, or null to build the default.</param>
|
||||
public ManifestExchangeClient(ILogger<ManifestExchangeClient> 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,
|
||||
};
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ManifestFetchOutcome?> FetchMovieAsync(
|
||||
IReadOnlyList<ManifestServer> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public async Task<ManifestFetchOutcome?> FetchEpisodeAsync(
|
||||
IReadOnlyList<ManifestServer> 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;
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> 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();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Servers that are enabled and not currently in backoff, in configured order.
|
||||
/// </summary>
|
||||
private IEnumerable<ManifestServer> Eligible(IReadOnlyList<ManifestServer> 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<ManifestFetchOutcome?> 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<ManifestFetchResponse>(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;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads a response body, aborting once it exceeds <paramref name="cap"/>.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Capped <b>while streaming</b> rather than after buffering: a hostile
|
||||
/// server can declare any <c>Content-Length</c> it likes, so reading to
|
||||
/// completion and then measuring is exactly the denial-of-service primitive
|
||||
/// the cap exists to prevent.
|
||||
/// </remarks>
|
||||
private static async Task<string?> 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());
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Builds a fetch URL, or null when the server's URL is unusable.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>HTTPS is required for anything that is not loopback.</b> A plaintext
|
||||
/// community server would let any network intermediary rewrite actor
|
||||
/// overlays, and the overlay is displayed to the user as fact.
|
||||
/// </remarks>
|
||||
/// <param name="server">The configured server.</param>
|
||||
/// <param name="path">API path below <c>/api/v1/</c>.</param>
|
||||
/// <param name="query">Identity and cut parameters.</param>
|
||||
/// <returns>The URL to request, or null when the server URL is unusable.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when the URL may be used: HTTPS anywhere, or HTTP on loopback only.
|
||||
/// </summary>
|
||||
/// <param name="uri">The server base URL.</param>
|
||||
/// <returns><c>true</c> when the transport is acceptable.</returns>
|
||||
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;
|
||||
}
|
||||
|
||||
/// <summary>Parses a tier name the server reported.</summary>
|
||||
/// <param name="tier">The tier string.</param>
|
||||
/// <returns>The tier, or null when unrecognised.</returns>
|
||||
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);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
public void Dispose()
|
||||
{
|
||||
Dispose(true);
|
||||
GC.SuppressFinalize(this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Releases the transport when this instance created it.
|
||||
/// </summary>
|
||||
/// <param name="disposing">Whether managed resources should be released.</param>
|
||||
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; }
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>A manifest accepted from a server.</summary>
|
||||
public class ManifestFetchOutcome
|
||||
{
|
||||
/// <summary>Gets or sets which server supplied it.</summary>
|
||||
public string ServerUrl { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the cut-match tier achieved.</summary>
|
||||
public MatchTier Tier { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the offset the client must apply to every window.</summary>
|
||||
public double OffsetSec { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the manifest.</summary>
|
||||
public Jmanifest? Manifest { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>
|
||||
/// Re-validates a manifest received from a server.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// <b>Every server is untrusted, including the pre-configured community one.</b>
|
||||
/// 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.
|
||||
/// <para>
|
||||
/// 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.
|
||||
/// </para>
|
||||
/// </remarks>
|
||||
// TRACES: JR-027 | SR-004
|
||||
public static class ManifestValidator
|
||||
{
|
||||
/// <summary>The exchange envelope version this plugin speaks (SR-003).</summary>
|
||||
public const int SupportedJmanifestVersion = 2;
|
||||
|
||||
/// <summary>Server spec §6: no more than 500 actors in one manifest.</summary>
|
||||
public const int MaxActors = 500;
|
||||
|
||||
/// <summary>Server spec §6: no more than 2000 windows for one actor.</summary>
|
||||
public const int MaxScenesPerActor = 2000;
|
||||
|
||||
/// <summary>Server spec §6: no more than 20000 windows in total.</summary>
|
||||
public const int MaxTotalScenes = 20000;
|
||||
|
||||
/// <summary>Server spec §6: names are capped at 200 characters.</summary>
|
||||
public const int MaxNameLength = 200;
|
||||
|
||||
/// <summary>
|
||||
/// Windows may exceed the measured runtime by this much before being
|
||||
/// rejected, covering rounding and container-duration disagreement.
|
||||
/// </summary>
|
||||
public const double RuntimeToleranceSec = 5.0;
|
||||
|
||||
/// <summary>
|
||||
/// Validates a manifest against the local item's measured runtime.
|
||||
/// </summary>
|
||||
/// <param name="manifest">The manifest as received.</param>
|
||||
/// <param name="localRuntimeSec">
|
||||
/// The runtime of the local file, or null when it is not known. Windows are
|
||||
/// bounds-checked against it when it is available.
|
||||
/// </param>
|
||||
/// <param name="error">The first problem found, naming the offending field.</param>
|
||||
/// <returns><c>true</c> when the manifest is safe to store.</returns>
|
||||
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<string>(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);
|
||||
|
||||
/// <summary>
|
||||
/// 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.
|
||||
/// </summary>
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Per-server reachability, for the configuration page.</summary>
|
||||
public class ServerStatus
|
||||
{
|
||||
/// <summary>Gets or sets the server's base URL.</summary>
|
||||
public string Url { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets the display name.</summary>
|
||||
public string Name { get; set; } = string.Empty;
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the server is enabled.</summary>
|
||||
public bool Enabled { get; set; }
|
||||
|
||||
/// <summary>Gets or sets a value indicating whether the last attempt succeeded.</summary>
|
||||
public bool Reachable { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the last error seen, if any.</summary>
|
||||
public string? LastError { get; set; }
|
||||
|
||||
/// <summary>Gets or sets when this server will next be tried.</summary>
|
||||
public DateTimeOffset? SkippedUntil { get; set; }
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
/// <summary>Identity and cut parameters for a fetch.</summary>
|
||||
public class TitleQuery
|
||||
{
|
||||
/// <summary>Gets or sets the movie's TMDB id.</summary>
|
||||
public string? TmdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the movie's IMDB id.</summary>
|
||||
public string? ImdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the series TMDB id, for an episode.</summary>
|
||||
public string? SeriesTmdbId { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the season number, for an episode.</summary>
|
||||
public int? Season { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the episode number, for an episode.</summary>
|
||||
public int? Episode { get; set; }
|
||||
|
||||
/// <summary>Gets or sets the local file's measured runtime, in seconds.</summary>
|
||||
public double? RuntimeSec { get; set; }
|
||||
|
||||
/// <summary>Renders the query parameters the server expects.</summary>
|
||||
/// <remarks>
|
||||
/// There is deliberately no <c>video_hash</c> parameter. The file-hash tier
|
||||
/// is withdrawn on legal grounds (see <see cref="MatchTier"/>), 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.
|
||||
/// </remarks>
|
||||
/// <returns>An escaped query string, without the leading '?'.</returns>
|
||||
public string ToQueryString()
|
||||
{
|
||||
var parts = new List<string>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
Reference in New Issue
Block a user