Manifest fetch across the configured servers (JR-025 … JR-037)
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 40s
🧪 Test Plugin / test (push) Successful in 26s

Satisfies JRay-public-server UR-007. Servers are tried in configured order and
the first result clearing the configured tier wins; first-match rather than
best-match because querying every server for every item multiplies egress and
leaks the library to more parties, and the ordering already encodes which
source the admin prefers.

Every server is untrusted, including the pre-configured community one, so a
fetched manifest is re-validated against the same rules the server applies on
upload: envelope version refused if unknown, identifiers format-checked,
windows bounds-checked against the *local* file's runtime, belief bounded to
[0, 1], control and bidi characters refused in names. Responses are capped
while streaming rather than after buffering, since a hostile server can declare
any Content-Length it likes. HTTPS is required away from loopback. A failing
server is skipped with exponential backoff so one dead server cannot stall a
sweep.

The audio-tier offset is applied once, at store time, so stored truth is always
in the local file's own timebase and no read path needs offset awareness.
Windows are shifted, never reshaped — merging adjacent ones would answer "was a
face visible" rather than "was the actor present" (SR-002).

Also records why there is no `exact` tier, which was missing and led me to
re-add one. The file-hash tier is withdrawn on legal grounds: a TMDB id
discloses "some copy of this film", but an OpenSubtitles hash discloses "this
exact release", which turns a catalogue lookup into a release-identification
service and a server's database into a mapping from file fingerprints to the
instances holding them. The reason now lives on MatchTier and in SPEC.md §JR-036,
`TitleQuery` has no VideoHash property so there is nothing to send, and a test
asserts the enum has no Exact member — the spec had still listed `exact` as a
configurable tier, which is what made the removal look like an oversight.

42 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: JR-025, JR-027, JR-028, JR-029, JR-030, JR-031, JR-036, JR-037 | PR-005, PR-006
This commit is contained in:
2026-07-31 10:03:49 +02:00
co-authored by Claude Opus 5
parent 64f549ab35
commit 3d210b5bd3
23 changed files with 1858 additions and 18 deletions
@@ -0,0 +1,53 @@
using System.Collections.Generic;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Fetches actor-timeline manifests from the configured public servers.
/// </summary>
/// <remarks>
/// Satisfies <c>JRay-public-server</c> UR-007: the plugin queries a configurable,
/// <b>ordered</b> list of servers, and the first result clearing the configured
/// match tier wins.
/// </remarks>
// TRACES: JR-025 | PR-006
public interface IManifestExchangeClient
{
/// <summary>
/// Fetches a movie manifest from the first server that has an acceptable one.
/// </summary>
/// <param name="servers">The configured servers, in trust order.</param>
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
/// <param name="query">Identity and cut parameters for the local item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The accepted manifest, or null when no server had one.</returns>
Task<ManifestFetchOutcome?> FetchMovieAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Fetches a single episode manifest.
/// </summary>
/// <param name="servers">The configured servers, in trust order.</param>
/// <param name="minimumTier">The lowest cut-match tier that may be stored.</param>
/// <param name="query">Identity and cut parameters for the local item.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>The accepted manifest, or null when no server had one.</returns>
Task<ManifestFetchOutcome?> FetchEpisodeAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken);
/// <summary>
/// Per-server reachability and last error, for the configuration page.
/// </summary>
/// <param name="servers">The configured servers.</param>
/// <returns>One status entry per configured server.</returns>
IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> servers);
}
@@ -0,0 +1,100 @@
using System;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Converts a fetched manifest into the truth file the plugin stores.
/// </summary>
// TRACES: JR-030 | SR-002, SR-003
public static class ManifestConverter
{
/// <summary>
/// Builds a truth file from a manifest, shifting every window by
/// <paramref name="offsetSec"/>.
/// </summary>
/// <remarks>
/// <b>The offset is applied here, once, at store time.</b> The server returns
/// it and the client applies it, so a single stored manifest serves every
/// trim of the same cut without ever being rewritten upstream. Applying it on
/// the way in means the stored truth is always in the local file's own
/// timebase, so the overlay and the <c>jray?t=</c> query need no offset
/// awareness at read time — the alternative would put the same correction in
/// every reader, forever, and one of them would eventually forget.
/// <para>
/// Windows are shifted, never reshaped: a window is a claim about scene
/// membership (SR-002), so merging or trimming would answer a different
/// question than the one the extraction pipeline answered.
/// </para>
/// </remarks>
/// <param name="manifest">The validated manifest.</param>
/// <param name="offsetSec">Seconds to add to every window.</param>
/// <param name="mediaPath">Local media path, recorded informationally.</param>
/// <returns>The truth file to store.</returns>
public static TruthFile ToTruthFile(Jmanifest manifest, double offsetSec, string mediaPath)
{
ArgumentNullException.ThrowIfNull(manifest);
var truth = new TruthFile
{
SchemaVersion = 1,
Movie = mediaPath ?? string.Empty,
SampleFps = manifest.Extraction?.SampleFps ?? 0,
};
foreach (var actor in manifest.Actors)
{
var converted = new TruthActor
{
Name = actor.Name ?? string.Empty,
ImdbId = actor.ImdbId ?? string.Empty,
TmdbId = actor.TmdbId ?? string.Empty,
};
foreach (var scene in actor.Scenes)
{
// Clamped at zero: a negative offset on an early window would
// otherwise produce a start before the file begins, which no
// reader can index.
var start = Math.Max(0, scene.Start + offsetSec);
var end = Math.Max(start, scene.End + offsetSec);
converted.Scenes.Add(new[] { start, end });
}
truth.Actors.Add(converted);
}
return truth;
}
/// <summary>
/// A short, human-readable description of how a manifest matched, for the
/// UI to show as a caveat.
/// </summary>
/// <remarks>
/// A <c>loose</c> match should surface as a caveat rather than being applied
/// silently: it means the runtimes differ by up to 30 seconds, which is
/// usually a different trim of the same cut but is not guaranteed to be.
/// </remarks>
/// <param name="tier">The tier achieved.</param>
/// <param name="offsetSec">The offset applied.</param>
/// <returns>A caveat string, or null when the match needs no explanation.</returns>
public static string? DescribeCaveat(MatchTier tier, double offsetSec)
{
if (tier == MatchTier.Loose)
{
return "Matched loosely — the runtime differs from this server's copy, so timings may drift.";
}
if (Math.Abs(offsetSec) > 0.001)
{
return string.Create(
CultureInfo.InvariantCulture,
$"Matched by audio content and shifted by {offsetSec:0.##}s to align with this file.");
}
return null;
}
}
@@ -0,0 +1,447 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Net.Http;
using System.Text.Json;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Fetches actor-timeline manifests from the configured servers.
/// </summary>
/// <remarks>
/// Servers are an <b>ordered list</b>, and order is the user's trust ranking made
/// explicit: for a fetch, servers are tried in order and the <i>first acceptable</i>
/// result wins — acceptable meaning it clears the configured match tier.
/// <para>
/// First-match rather than best-match is deliberate. Querying every server for
/// every item multiplies egress, leaks the library to more parties, and the
/// ordering already encodes which source the admin prefers. Each configured
/// server multiplies the privacy exposure described in the server spec §9, so
/// later servers are queried only for what earlier ones lacked.
/// </para>
/// </remarks>
// TRACES: JR-025, JR-029, JR-030, JR-037 | PR-005, PR-006
public class ManifestExchangeClient : IManifestExchangeClient, IDisposable
{
/// <summary>Server spec §9: a single manifest response is capped at 2 MiB.</summary>
public const long MaxManifestBytes = 2 * 1024 * 1024;
/// <summary>Server spec §9: a bundle response is capped at 25 MiB.</summary>
public const long MaxBundleBytes = 25L * 1024 * 1024;
private static readonly TimeSpan ConnectTimeout = TimeSpan.FromSeconds(5);
private static readonly TimeSpan ReadTimeout = TimeSpan.FromSeconds(30);
/// <summary>
/// How long a server that failed is skipped for, doubling each consecutive
/// failure. One dead server must never stall a library sweep.
/// </summary>
private static readonly TimeSpan BaseBackoff = TimeSpan.FromMinutes(1);
private static readonly TimeSpan MaxBackoff = TimeSpan.FromHours(1);
private readonly HttpClient _http;
private readonly ILogger<ManifestExchangeClient> _logger;
private readonly ConcurrentDictionary<string, ServerHealth> _health = new(StringComparer.Ordinal);
private readonly bool _ownsClient;
/// <summary>
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class.
/// </summary>
/// <param name="logger">Logger.</param>
public ManifestExchangeClient(ILogger<ManifestExchangeClient> logger)
: this(logger, null)
{
}
/// <summary>
/// Initializes a new instance of the <see cref="ManifestExchangeClient"/> class
/// with an injected transport, for testing.
/// </summary>
/// <param name="logger">Logger.</param>
/// <param name="httpClient">Transport to use, or null to build the default.</param>
public ManifestExchangeClient(ILogger<ManifestExchangeClient> logger, HttpClient? httpClient)
{
_logger = logger;
_ownsClient = httpClient is null;
_http = httpClient ?? new HttpClient(new SocketsHttpHandler
{
ConnectTimeout = ConnectTimeout,
// Certificate validation is never disabled: a plaintext or
// unverified server would let any network intermediary rewrite
// actor overlays.
AutomaticDecompression = System.Net.DecompressionMethods.All,
})
{
Timeout = ReadTimeout,
};
}
/// <inheritdoc />
public async Task<ManifestFetchOutcome?> FetchMovieAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(servers);
ArgumentNullException.ThrowIfNull(query);
foreach (var server in Eligible(servers))
{
var url = BuildUrl(server, "manifests/movie", query);
if (url is null)
{
continue;
}
var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
.ConfigureAwait(false);
if (outcome is not null)
{
// First acceptable result wins — no further servers are queried,
// which is what bounds the privacy exposure.
return outcome;
}
}
return null;
}
/// <inheritdoc />
public async Task<ManifestFetchOutcome?> FetchEpisodeAsync(
IReadOnlyList<ManifestServer> servers,
MatchTier minimumTier,
TitleQuery query,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(servers);
ArgumentNullException.ThrowIfNull(query);
foreach (var server in Eligible(servers))
{
var url = BuildUrl(server, "manifests/episode", query);
if (url is null)
{
continue;
}
var outcome = await TryFetchOneAsync(server, url, query, minimumTier, cancellationToken)
.ConfigureAwait(false);
if (outcome is not null)
{
return outcome;
}
}
return null;
}
/// <inheritdoc />
public IReadOnlyList<ServerStatus> GetStatus(IReadOnlyList<ManifestServer> servers)
{
ArgumentNullException.ThrowIfNull(servers);
return servers.Select(s =>
{
_health.TryGetValue(s.Url, out var h);
return new ServerStatus
{
Url = s.Url,
Name = s.Name,
Enabled = s.Enabled,
Reachable = h is null || h.ConsecutiveFailures == 0,
LastError = h?.LastError,
SkippedUntil = h?.SkipUntil,
};
}).ToList();
}
/// <summary>
/// Servers that are enabled and not currently in backoff, in configured order.
/// </summary>
private IEnumerable<ManifestServer> Eligible(IReadOnlyList<ManifestServer> servers)
{
var now = DateTimeOffset.UtcNow;
foreach (var s in servers)
{
if (!s.Enabled || string.IsNullOrWhiteSpace(s.Url))
{
continue;
}
if (_health.TryGetValue(s.Url, out var h) && h.SkipUntil > now)
{
_logger.LogDebug("Skipping {Url} until {Until} after {Failures} failures", s.Url, h.SkipUntil, h.ConsecutiveFailures);
continue;
}
yield return s;
}
}
private async Task<ManifestFetchOutcome?> TryFetchOneAsync(
ManifestServer server,
Uri url,
TitleQuery query,
MatchTier minimumTier,
CancellationToken cancellationToken)
{
try
{
using var response = await _http
.GetAsync(url, HttpCompletionOption.ResponseHeadersRead, cancellationToken)
.ConfigureAwait(false);
if (response.StatusCode == System.Net.HttpStatusCode.NotFound)
{
// Not an error: this server simply does not hold it. The next
// server in the list gets a turn.
RecordSuccess(server.Url);
return null;
}
if (!response.IsSuccessStatusCode)
{
RecordFailure(server.Url, $"HTTP {(int)response.StatusCode}");
return null;
}
var json = await ReadCappedAsync(response, MaxManifestBytes, cancellationToken)
.ConfigureAwait(false);
if (json is null)
{
RecordFailure(server.Url, "response exceeded the size cap");
return null;
}
var body = JsonSerializer.Deserialize<ManifestFetchResponse>(json);
RecordSuccess(server.Url);
if (body?.Manifest is null)
{
return null;
}
var tier = ParseTier(body.Match);
if (tier is null || tier < minimumTier)
{
_logger.LogDebug(
"{Url} matched at {Tier}, below the configured minimum {Minimum}",
server.Url,
body.Match,
minimumTier);
return null;
}
if (!ManifestValidator.TryValidate(body.Manifest, query.RuntimeSec, out var error))
{
// A manifest is never trusted merely because a server served it.
_logger.LogWarning("Rejected manifest from {Url}: {Error}", server.Url, error);
return null;
}
return new ManifestFetchOutcome
{
ServerUrl = server.Url,
Tier = tier.Value,
OffsetSec = body.OffsetSec,
Manifest = body.Manifest,
};
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex) when (ex is HttpRequestException or TaskCanceledException or JsonException)
{
// A slow, unreachable or nonsense-returning server is skipped and
// backed off; it must never stall the sweep or fail the whole fetch.
RecordFailure(server.Url, ex.Message);
return null;
}
}
/// <summary>
/// Reads a response body, aborting once it exceeds <paramref name="cap"/>.
/// </summary>
/// <remarks>
/// Capped <b>while streaming</b> rather than after buffering: a hostile
/// server can declare any <c>Content-Length</c> it likes, so reading to
/// completion and then measuring is exactly the denial-of-service primitive
/// the cap exists to prevent.
/// </remarks>
private static async Task<string?> ReadCappedAsync(
HttpResponseMessage response,
long cap,
CancellationToken cancellationToken)
{
// The declared length is a cheap early rejection, never the enforcement.
if (response.Content.Headers.ContentLength is { } declared && declared > cap)
{
return null;
}
using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false);
var buffer = new byte[8192];
using var accumulated = new System.IO.MemoryStream();
while (true)
{
var read = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false);
if (read == 0)
{
break;
}
if (accumulated.Length + read > cap)
{
return null;
}
await accumulated.WriteAsync(buffer.AsMemory(0, read), cancellationToken).ConfigureAwait(false);
}
return System.Text.Encoding.UTF8.GetString(accumulated.ToArray());
}
/// <summary>
/// Builds a fetch URL, or null when the server's URL is unusable.
/// </summary>
/// <remarks>
/// <b>HTTPS is required for anything that is not loopback.</b> A plaintext
/// community server would let any network intermediary rewrite actor
/// overlays, and the overlay is displayed to the user as fact.
/// </remarks>
/// <param name="server">The configured server.</param>
/// <param name="path">API path below <c>/api/v1/</c>.</param>
/// <param name="query">Identity and cut parameters.</param>
/// <returns>The URL to request, or null when the server URL is unusable.</returns>
internal static Uri? BuildUrl(ManifestServer server, string path, TitleQuery query)
{
ArgumentNullException.ThrowIfNull(server);
ArgumentNullException.ThrowIfNull(query);
if (!Uri.TryCreate(server.Url, UriKind.Absolute, out var baseUri))
{
return null;
}
if (!IsTransportAcceptable(baseUri))
{
return null;
}
var q = query.ToQueryString();
var trimmed = baseUri.AbsoluteUri.TrimEnd('/');
return Uri.TryCreate($"{trimmed}/api/v1/{path}?{q}", UriKind.Absolute, out var built)
? built
: null;
}
/// <summary>
/// True when the URL may be used: HTTPS anywhere, or HTTP on loopback only.
/// </summary>
/// <param name="uri">The server base URL.</param>
/// <returns><c>true</c> when the transport is acceptable.</returns>
internal static bool IsTransportAcceptable(Uri uri)
{
ArgumentNullException.ThrowIfNull(uri);
if (uri.Scheme == Uri.UriSchemeHttps)
{
return true;
}
if (uri.Scheme != Uri.UriSchemeHttp)
{
return false;
}
// Loopback is exempt because there is no network path to intercept.
return uri.IsLoopback;
}
/// <summary>Parses a tier name the server reported.</summary>
/// <param name="tier">The tier string.</param>
/// <returns>The tier, or null when unrecognised.</returns>
internal static MatchTier? ParseTier(string? tier) => tier switch
{
// `exact` is deliberately absent: the file-hash tier is withdrawn on
// legal grounds (see MatchTier). A server cannot report it to us anyway,
// since we send no `video_hash` — and if one did, treating it as
// unrecognised means the manifest is declined rather than silently
// accepted under a tier this plugin has no policy for.
"audio" => MatchTier.Audio,
"runtime" => MatchTier.Runtime,
"loose" => MatchTier.Loose,
_ => null,
};
private void RecordSuccess(string url) => _health.TryRemove(url, out _);
private void RecordFailure(string url, string error)
{
var updated = _health.AddOrUpdate(
url,
_ => new ServerHealth { ConsecutiveFailures = 1, LastError = error, SkipUntil = DateTimeOffset.UtcNow + BaseBackoff },
(_, existing) =>
{
var failures = existing.ConsecutiveFailures + 1;
// Exponential, capped: a server that is down for a day should
// not be retried every minute for that whole day.
var delayTicks = Math.Min(
BaseBackoff.Ticks * (long)Math.Pow(2, Math.Min(failures - 1, 6)),
MaxBackoff.Ticks);
return new ServerHealth
{
ConsecutiveFailures = failures,
LastError = error,
SkipUntil = DateTimeOffset.UtcNow + TimeSpan.FromTicks(delayTicks),
};
});
_logger.LogWarning(
"Server {Url} failed ({Failures} consecutive): {Error}. Skipping until {Until}",
url,
updated.ConsecutiveFailures,
error,
updated.SkipUntil);
}
/// <inheritdoc />
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
/// <summary>
/// Releases the transport when this instance created it.
/// </summary>
/// <param name="disposing">Whether managed resources should be released.</param>
protected virtual void Dispose(bool disposing)
{
if (disposing && _ownsClient)
{
_http.Dispose();
}
}
private sealed class ServerHealth
{
public int ConsecutiveFailures { get; init; }
public string? LastError { get; init; }
public DateTimeOffset SkipUntil { get; init; }
}
}
@@ -0,0 +1,23 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>A manifest accepted from a server.</summary>
public class ManifestFetchOutcome
{
/// <summary>Gets or sets which server supplied it.</summary>
public string ServerUrl { get; set; } = string.Empty;
/// <summary>Gets or sets the cut-match tier achieved.</summary>
public MatchTier Tier { get; set; }
/// <summary>Gets or sets the offset the client must apply to every window.</summary>
public double OffsetSec { get; set; }
/// <summary>Gets or sets the manifest.</summary>
public Jmanifest? Manifest { get; set; }
}
@@ -0,0 +1,256 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Re-validates a manifest received from a server.
/// </summary>
/// <remarks>
/// <b>Every server is untrusted, including the pre-configured community one.</b>
/// Everything the server specification guarantees is a property of a *correctly
/// operated* server; pointing the plugin at an arbitrary URL inherits none of
/// it. So the plugin re-applies client-side what the server applies on upload:
/// unknown-shaped data rejected, identifiers format-checked, windows
/// bounds-checked against the item's real runtime.
/// <para>
/// The honest framing for the configuration page is that adding a third-party
/// server means trusting its operator not to serve you deliberately wrong actor
/// data. These checks bound the damage to bad overlay content; they cannot make
/// wrong data right.
/// </para>
/// </remarks>
// TRACES: JR-027 | SR-004
public static class ManifestValidator
{
/// <summary>The exchange envelope version this plugin speaks (SR-003).</summary>
public const int SupportedJmanifestVersion = 2;
/// <summary>Server spec §6: no more than 500 actors in one manifest.</summary>
public const int MaxActors = 500;
/// <summary>Server spec §6: no more than 2000 windows for one actor.</summary>
public const int MaxScenesPerActor = 2000;
/// <summary>Server spec §6: no more than 20000 windows in total.</summary>
public const int MaxTotalScenes = 20000;
/// <summary>Server spec §6: names are capped at 200 characters.</summary>
public const int MaxNameLength = 200;
/// <summary>
/// Windows may exceed the measured runtime by this much before being
/// rejected, covering rounding and container-duration disagreement.
/// </summary>
public const double RuntimeToleranceSec = 5.0;
/// <summary>
/// Validates a manifest against the local item's measured runtime.
/// </summary>
/// <param name="manifest">The manifest as received.</param>
/// <param name="localRuntimeSec">
/// The runtime of the local file, or null when it is not known. Windows are
/// bounds-checked against it when it is available.
/// </param>
/// <param name="error">The first problem found, naming the offending field.</param>
/// <returns><c>true</c> when the manifest is safe to store.</returns>
public static bool TryValidate(Jmanifest? manifest, double? localRuntimeSec, out string error)
{
if (manifest is null)
{
error = "manifest: absent";
return false;
}
// An unknown envelope version is refused, never guessed at (JR-003).
// A server one version ahead may have changed the meaning of a field
// this plugin thinks it understands.
if (manifest.JmanifestVersion != SupportedJmanifestVersion)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"jmanifest_version: unsupported version {manifest.JmanifestVersion}, expected {SupportedJmanifestVersion}");
return false;
}
if (manifest.Identity is null)
{
error = "identity: absent";
return false;
}
if (manifest.Cut is null || !IsSaneRuntime(manifest.Cut.RuntimeSec))
{
error = "cut.runtime_sec: absent or not a plausible duration";
return false;
}
if (manifest.Actors.Count == 0)
{
error = "actors: empty";
return false;
}
if (manifest.Actors.Count > MaxActors)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors: more than {MaxActors} entries");
return false;
}
// Bounds are checked against the *local* file where known, because that
// is what the overlay will index into. A window past the end of the file
// is not merely useless, it is evidence the manifest is for another cut.
var limit = (localRuntimeSec ?? manifest.Cut.RuntimeSec) + RuntimeToleranceSec;
var total = 0;
var seenTmdb = new HashSet<string>(StringComparer.Ordinal);
for (var i = 0; i < manifest.Actors.Count; i++)
{
var actor = manifest.Actors[i];
if (actor.Name is { Length: > MaxNameLength })
{
error = string.Create(CultureInfo.InvariantCulture, $"actors[{i}].name: too long");
return false;
}
if (actor.Name is not null && ContainsControlCharacters(actor.Name))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].name: contains control characters");
return false;
}
if (actor.TmdbId is { Length: > 0 } tmdb)
{
if (!IsDigits(tmdb, 9))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].tmdb_id: not a TMDB id");
return false;
}
if (!seenTmdb.Add(tmdb))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].tmdb_id: duplicate actor");
return false;
}
}
if (actor.ImdbId is { Length: > 0 } imdb && !IsPersonImdbId(imdb))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].imdb_id: not an IMDB person id");
return false;
}
if (actor.Scenes.Count > MaxScenesPerActor)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes: more than {MaxScenesPerActor} entries");
return false;
}
total += actor.Scenes.Count;
if (total > MaxTotalScenes)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors: more than {MaxTotalScenes} windows in total");
return false;
}
for (var j = 0; j < actor.Scenes.Count; j++)
{
var scene = actor.Scenes[j];
if (!IsFinite(scene.Start) || !IsFinite(scene.End))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}]: non-finite value");
return false;
}
if (scene.Start < 0 || scene.End < scene.Start)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}]: negative or inverted window");
return false;
}
if (scene.End > limit)
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}]: ends beyond the item's runtime");
return false;
}
// A posterior outside [0, 1] is not a probability.
if (scene.Belief is { } b && (!IsFinite(b) || b < 0 || b > 1))
{
error = string.Create(
CultureInfo.InvariantCulture,
$"actors[{i}].scenes[{j}].belief: outside [0, 1]");
return false;
}
}
}
error = string.Empty;
return true;
}
private static bool IsSaneRuntime(double v) => IsFinite(v) && v > 0 && v < 200_000;
private static bool IsFinite(double v) => !double.IsNaN(v) && !double.IsInfinity(v);
private static bool IsDigits(string s, int maxLength) =>
s.Length > 0 && s.Length <= maxLength && s.All(char.IsAsciiDigit);
private static bool IsPersonImdbId(string s) =>
s.StartsWith("nm", StringComparison.Ordinal)
&& (s.Length == 9 || s.Length == 10)
&& s.AsSpan(2).ToString().All(char.IsAsciiDigit);
/// <summary>
/// Control characters are refused outright. The overlay renders names as
/// text nodes (JR-024), so markup is already inert, but a bidi override or a
/// zero-width joiner can still make a name display as something other than
/// what was stored.
/// </summary>
private static bool ContainsControlCharacters(string s)
{
foreach (var c in s)
{
if (char.IsControl(c))
{
return true;
}
// Zero-width and bidi-control codepoints.
if (c is >= '' and <= ''
or >= '' and <= ''
or >= '' and <= ''
or '')
{
return true;
}
}
return false;
}
}
@@ -0,0 +1,29 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>Per-server reachability, for the configuration page.</summary>
public class ServerStatus
{
/// <summary>Gets or sets the server's base URL.</summary>
public string Url { get; set; } = string.Empty;
/// <summary>Gets or sets the display name.</summary>
public string Name { get; set; } = string.Empty;
/// <summary>Gets or sets a value indicating whether the server is enabled.</summary>
public bool Enabled { get; set; }
/// <summary>Gets or sets a value indicating whether the last attempt succeeded.</summary>
public bool Reachable { get; set; }
/// <summary>Gets or sets the last error seen, if any.</summary>
public string? LastError { get; set; }
/// <summary>Gets or sets when this server will next be tried.</summary>
public DateTimeOffset? SkippedUntil { get; set; }
}
@@ -0,0 +1,57 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>Identity and cut parameters for a fetch.</summary>
public class TitleQuery
{
/// <summary>Gets or sets the movie's TMDB id.</summary>
public string? TmdbId { get; set; }
/// <summary>Gets or sets the movie's IMDB id.</summary>
public string? ImdbId { get; set; }
/// <summary>Gets or sets the series TMDB id, for an episode.</summary>
public string? SeriesTmdbId { get; set; }
/// <summary>Gets or sets the season number, for an episode.</summary>
public int? Season { get; set; }
/// <summary>Gets or sets the episode number, for an episode.</summary>
public int? Episode { get; set; }
/// <summary>Gets or sets the local file's measured runtime, in seconds.</summary>
public double? RuntimeSec { get; set; }
/// <summary>Renders the query parameters the server expects.</summary>
/// <remarks>
/// There is deliberately no <c>video_hash</c> parameter. The file-hash tier
/// is withdrawn on legal grounds (see <see cref="MatchTier"/>), and omitting
/// the field here is what makes that structural: there is nothing to send,
/// so no future caller can start sending one by setting a property.
/// </remarks>
/// <returns>An escaped query string, without the leading '?'.</returns>
public string ToQueryString()
{
var parts = new List<string>();
void Add(string key, string? value)
{
if (!string.IsNullOrEmpty(value))
{
parts.Add($"{key}={Uri.EscapeDataString(value)}");
}
}
Add("tmdb_id", TmdbId);
Add("imdb_id", ImdbId);
Add("series_tmdb_id", SeriesTmdbId);
Add("season", Season?.ToString(CultureInfo.InvariantCulture));
Add("episode", Episode?.ToString(CultureInfo.InvariantCulture));
Add("runtime_sec", RuntimeSec?.ToString("0.###", CultureInfo.InvariantCulture));
return string.Join('&', parts);
}
}