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