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", "Steve‮imecsuB", "Steve​Buscemi" }) { 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((140.0, 160.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End)); Assert.Equal((240.0, 260.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End)); } [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].Start); Assert.True(truth.Actors[0].Scenes[0].End >= truth.Actors[0].Scenes[0].Start); } [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((10.0, 20.0), (truth.Actors[0].Scenes[0].Start, truth.Actors[0].Scenes[0].End)); Assert.Equal((20.0, 30.0), (truth.Actors[0].Scenes[1].Start, truth.Actors[0].Scenes[1].End)); } [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)); } }