using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
///
/// Tests for the manifest exchange: tier policy, transport rules, validation on
/// receipt, and offset application.
///
public class ManifestExchangeTests
{
private static ManifestServer Server(string url) =>
new() { Url = url, Name = "test", Enabled = true };
private static TitleQuery Query() =>
new() { TmdbId = "504172", RuntimeSec = 6420.5 };
// -----------------------------------------------------------------------
// The withdrawn file-hash tier
// -----------------------------------------------------------------------
[Fact]
public void MatchTierHasNoExactMember()
{
// The `exact` tier keyed on an OpenSubtitles file hash and was withdrawn
// on legal grounds: a file hash identifies the exact release a user
// holds, not the cut the timings describe, so sending one turns a
// catalogue lookup into a release-identification service.
//
// Asserted on the enum rather than trusted, because "we removed it" is
// exactly the kind of decision a later reader re-adds as an oversight.
var names = Enum.GetNames();
Assert.DoesNotContain("Exact", names);
Assert.Equal(new[] { "Loose", "Runtime", "Audio" }, names);
}
[Fact]
public void AudioIsTheTopTier()
{
// Content-derived, so it identifies the cut rather than the copy — which
// is what makes it an acceptable replacement for the file hash.
Assert.True(MatchTier.Audio > MatchTier.Runtime);
Assert.True(MatchTier.Runtime > MatchTier.Loose);
Assert.Equal(MatchTier.Audio, Enum.GetValues().Max());
}
[Fact]
public void NoVideoHashIsEverSent()
{
// Structural, not merely policy: `TitleQuery` has no VideoHash property,
// so there is nothing a future caller could populate.
Assert.Null(typeof(TitleQuery).GetProperty("VideoHash"));
var query = new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 };
Assert.DoesNotContain("video_hash", query.ToQueryString(), StringComparison.Ordinal);
}
[Fact]
public void AServerReportingTheWithdrawnTierIsDeclined()
{
// A server may still hold hashes from other clients. If one somehow
// reports `exact`, it is unrecognised rather than silently accepted
// under a tier this plugin has no policy for.
Assert.Null(ManifestExchangeClient.ParseTier("exact"));
Assert.Equal(MatchTier.Audio, ManifestExchangeClient.ParseTier("audio"));
Assert.Equal(MatchTier.Runtime, ManifestExchangeClient.ParseTier("runtime"));
Assert.Equal(MatchTier.Loose, ManifestExchangeClient.ParseTier("loose"));
Assert.Null(ManifestExchangeClient.ParseTier("nonsense"));
}
// -----------------------------------------------------------------------
// Transport
// -----------------------------------------------------------------------
[Theory]
[InlineData("https://jray.tourolle.paris", true)]
[InlineData("https://third.party.example", true)]
[InlineData("http://127.0.0.1:8080", true)]
[InlineData("http://localhost:8080", true)]
[InlineData("http://jray.tourolle.paris", false)]
[InlineData("http://192.168.1.10:8080", false)]
[InlineData("ftp://example.com", false)]
public void HttpsIsRequiredAwayFromLoopback(string url, bool acceptable)
{
// A plaintext server would let any network intermediary rewrite actor
// overlays, and the overlay is shown to the user as fact. Loopback is
// exempt because there is no network path to intercept.
Assert.Equal(acceptable, ManifestExchangeClient.IsTransportAcceptable(new Uri(url)));
}
[Fact]
public void AnUnusableServerUrlYieldsNoRequest()
{
Assert.Null(ManifestExchangeClient.BuildUrl(Server("http://example.com"), "manifests/movie", Query()));
Assert.Null(ManifestExchangeClient.BuildUrl(Server("not a url"), "manifests/movie", Query()));
}
[Fact]
public void QueryParametersAreEscaped()
{
var url = ManifestExchangeClient.BuildUrl(
Server("https://s.example/"),
"manifests/movie",
new TitleQuery { TmdbId = "504172", RuntimeSec = 6420.5 });
Assert.NotNull(url);
Assert.StartsWith("https://s.example/api/v1/manifests/movie?", url!.AbsoluteUri, StringComparison.Ordinal);
Assert.Contains("tmdb_id=504172", url.AbsoluteUri, StringComparison.Ordinal);
// Invariant formatting, so a comma-decimal locale cannot corrupt the runtime.
Assert.Contains("runtime_sec=6420.5", url.AbsoluteUri, StringComparison.Ordinal);
}
[Fact]
public void EpisodeCoordinatesAreSent()
{
var url = ManifestExchangeClient.BuildUrl(
Server("https://s.example"),
"manifests/episode",
new TitleQuery { SeriesTmdbId = "1396", Season = 2, Episode = 5, RuntimeSec = 2820 });
Assert.NotNull(url);
Assert.Contains("series_tmdb_id=1396", url!.AbsoluteUri, StringComparison.Ordinal);
Assert.Contains("season=2", url.AbsoluteUri, StringComparison.Ordinal);
Assert.Contains("episode=5", url.AbsoluteUri, StringComparison.Ordinal);
}
// -----------------------------------------------------------------------
// Validation on receipt — every server is untrusted
// -----------------------------------------------------------------------
private static Jmanifest ValidManifest()
{
var m = new Jmanifest { JmanifestVersion = 2 };
m.Identity = new JmanifestIdentity { Type = "movie", TmdbId = "504172" };
m.Cut = new JmanifestCut { RuntimeSec = 6420.5 };
var actor = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
actor.Scenes.Add(new JmanifestScene { Start = 191.6, End = 209.2, Belief = 0.98, Route = "live" });
m.Actors.Add(actor);
return m;
}
[Fact]
public void AWellFormedManifestValidates()
{
Assert.True(ManifestValidator.TryValidate(ValidManifest(), 6420.5, out var error), error);
}
[Fact]
public void AnUnknownEnvelopeVersionIsRefused()
{
// Never guessed at: a server one version ahead may have changed the
// meaning of a field this plugin thinks it understands.
foreach (var version in new[] { 0, 1, 3, 99 })
{
var m = ValidManifest();
m.JmanifestVersion = version;
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
Assert.Contains("jmanifest_version", error, StringComparison.Ordinal);
}
}
[Fact]
public void WindowsBeyondTheLocalRuntimeAreRejected()
{
// Bounds are checked against the *local* file, because that is what the
// overlay indexes into. A window past the end is evidence the manifest
// describes another cut.
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 9000 });
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
Assert.Contains("runtime", error, StringComparison.Ordinal);
}
[Fact]
public void InvertedNegativeAndNonFiniteWindowsAreRejected()
{
foreach (var scene in new[]
{
new JmanifestScene { Start = 50, End = 10 },
new JmanifestScene { Start = -1, End = 10 },
new JmanifestScene { Start = double.NaN, End = 10 },
new JmanifestScene { Start = 0, End = double.PositiveInfinity },
})
{
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(scene);
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
}
[Fact]
public void BeliefOutsideZeroToOneIsRejected()
{
foreach (var belief in new[] { -0.1, 1.5, double.NaN })
{
var m = ValidManifest();
m.Actors[0].Scenes[0].Belief = belief;
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out var error));
Assert.Contains("belief", error, StringComparison.Ordinal);
}
}
[Fact]
public void MalformedIdentifiersAreRejected()
{
var m = ValidManifest();
m.Actors[0].TmdbId = "884'; DROP TABLE--";
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
m = ValidManifest();
m.Actors[0].ImdbId = "tt0000114"; // a title id in a person field
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
[Fact]
public void ControlCharactersInANameAreRejected()
{
// The overlay renders names as text nodes, so markup is already inert —
// but a bidi override still makes a name display as something other than
// what was stored.
foreach (var name in new[] { "SteveBuscemi", "SteveimecsuB", "SteveBuscemi" })
{
var m = ValidManifest();
m.Actors[0].Name = name;
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
}
[Fact]
public void RealNamesAreAccepted()
{
foreach (var name in new[] { "Steve Buscemi", "Renée Zellweger", "宮崎 駿", "O'Brien" })
{
var m = ValidManifest();
m.Actors[0].Name = name;
Assert.True(ManifestValidator.TryValidate(m, 6420.5, out var error), $"{name}: {error}");
}
}
[Fact]
public void DuplicateActorsAreRejected()
{
var m = ValidManifest();
var dup = new JmanifestActor { Name = "Steve Buscemi", TmdbId = "884" };
dup.Scenes.Add(new JmanifestScene { Start = 1, End = 2 });
m.Actors.Add(dup);
Assert.False(ManifestValidator.TryValidate(m, 6420.5, out _));
}
// -----------------------------------------------------------------------
// Offset application
// -----------------------------------------------------------------------
[Fact]
public void TheOffsetIsAppliedToEveryWindow()
{
// Applied once, at store time, so the stored truth is always in the
// local file's timebase and no reader needs offset awareness.
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 100, End = 120 });
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 200, End = 220 });
var truth = ManifestConverter.ToTruthFile(m, 40, "/media/film.mkv");
Assert.Equal(new[] { 140.0, 160.0 }, truth.Actors[0].Scenes[0]);
Assert.Equal(new[] { 240.0, 260.0 }, truth.Actors[0].Scenes[1]);
}
[Fact]
public void ANegativeOffsetCannotPushAWindowBelowZero()
{
// A start before the file begins is not indexable by any reader.
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 5, End = 20 });
var truth = ManifestConverter.ToTruthFile(m, -40, "/media/film.mkv");
Assert.Equal(0.0, truth.Actors[0].Scenes[0][0]);
Assert.True(truth.Actors[0].Scenes[0][1] >= truth.Actors[0].Scenes[0][0]);
}
[Fact]
public void WindowsAreShiftedNeverReshaped()
{
// A window is a claim about scene membership (SR-002), so merging
// adjacent windows would answer a different question than the pipeline
// answered — "was a face visible" rather than "was the actor present".
var m = ValidManifest();
m.Actors[0].Scenes.Clear();
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 10, End = 20 });
m.Actors[0].Scenes.Add(new JmanifestScene { Start = 20, End = 30 });
var truth = ManifestConverter.ToTruthFile(m, 0, "/media/film.mkv");
Assert.Equal(2, truth.Actors[0].Scenes.Count);
Assert.Equal(new[] { 10.0, 20.0 }, truth.Actors[0].Scenes[0]);
Assert.Equal(new[] { 20.0, 30.0 }, truth.Actors[0].Scenes[1]);
}
[Fact]
public void ALooseMatchSurfacesACaveat()
{
// Applied as a caveat rather than silently: the runtimes differ by up to
// 30 seconds, which is usually a different trim of the same cut but is
// not guaranteed to be.
Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Loose, 0));
Assert.NotNull(ManifestConverter.DescribeCaveat(MatchTier.Audio, 40));
Assert.Null(ManifestConverter.DescribeCaveat(MatchTier.Runtime, 0));
}
}