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