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
101 lines
3.8 KiB
C#
101 lines
3.8 KiB
C#
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;
|
|
}
|
|
}
|