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
320 lines
13 KiB
C#
320 lines
13 KiB
C#
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));
|
||
}
|
||
}
|