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; } }