using System;
using System.IO;
using System.Text.Json;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Xunit;
namespace Jellyfin.Plugin.JRay.Tests;
///
/// JR-047 — a fetched manifest is aligned against the local file before its
/// windows are stored, and how that was decided is recorded.
///
/// The claim under test is that **the local alignment supersedes the server's
/// offset**. The server has never seen this file; its offset can only be a
/// runtime-difference inference, while a local alignment compares the manifest's
/// own signature against the media the windows will be drawn over.
///
/// The counterweight is that a signature must never break a fetch. Every way the
/// local path can fail — switched off, no manifest signature, short media, a
/// failed decode, or two signatures that do not match — has to fall back to the
/// server's offset rather than refusing. Both halves are asserted here.
///
/// `Resolve` is the decision, split out from the decode so it can be driven
/// without FFmpeg or a media file.
///
/// TRACES: UT-053, UT-054, UT-055, UT-056, UT-057 | JR-047
///
public class ManifestAlignerTests
{
private const double FeatureRuntime = 7200.0;
private const double ServerOffset = 3.5;
private static readonly string FixtureDir =
Path.Combine(AppContext.BaseDirectory, "fixtures", "audio");
private static readonly Lazy GoldenSignature = new(() =>
JsonDocument.Parse(File.ReadAllText(Path.Combine(FixtureDir, "jray_audio_v1_golden.json")))
.RootElement.GetProperty("signature").GetString()!);
private static readonly Lazy GoldenFrames = new(() =>
AudioSignatureMatcher.TryParseFrames(GoldenSignature.Value)!);
// UT-053
[Fact]
public void ALocalAlignment_SupersedesTheServersOffset()
{
// Same cut, sampled 100 frames apart — a differently trimmed release.
// The server offered 3.5 s from a runtime comparison; the local audio
// says otherwise, and the local answer is the one that gets applied.
const int Shift = 100;
var source = GoldenFrames.Value;
var alignment = ManifestAligner.Resolve(
Signature(source, 44, 1000),
Signature(source, 144, 1000),
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.Local, alignment.Source);
Assert.Equal(MatchTier.Audio, alignment.Tier);
Assert.Equal(Shift, alignment.OffsetFrames);
Assert.Equal(1.0, alignment.Score!.Value);
Assert.Equal(Shift * AudioSignatureMatcher.FrameSeconds, alignment.OffsetSec, 9);
// The server's claim is kept rather than overwritten: the applied offset
// is otherwise unrecoverable once the windows are shifted, and the two
// disagreeing is exactly what someone debugging would need to see.
Assert.Equal(ServerOffset, alignment.ServerOffsetSec);
Assert.Equal(MatchTier.Runtime, alignment.ServerTier);
}
// UT-054
[Fact]
public void TheLocalSignature_IsRecorded_SoALaterFetchNeedNotDecodeAgain()
{
// The decode is the expensive half and the reason signatures are opt-in.
// Keeping the local one beside the truth file is what lets a second
// manifest be aligned for free.
var alignment = ManifestAligner.Resolve(
GoldenSignature.Value,
GoldenSignature.Value,
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(GoldenSignature.Value, alignment.LocalSignature);
Assert.Equal(AlignmentSource.Local, alignment.Source);
Assert.Equal(0.0, alignment.OffsetSec);
}
// UT-055
[Fact]
public void EveryUnavailableLocalPath_FallsBackToTheServer_RatherThanRefusing()
{
// A signature is an enhancement to cut matching. A missing one costs a
// tier; it must never be able to break a fetch, so each of these stores
// the manifest on the server's terms.
var cases = new (string? Local, string? Manifest, string Why)[]
{
(null, GoldenSignature.Value, "signatures off, or the decode failed"),
(GoldenSignature.Value, null, "the manifest carried no signature"),
(null, null, "neither side has one"),
};
foreach (var (local, manifest, why) in cases)
{
var alignment = ManifestAligner.Resolve(
local, manifest, FeatureRuntime, FeatureRuntime, MatchTier.Runtime, ServerOffset);
Assert.Equal(AlignmentSource.Server, alignment.Source);
Assert.Equal(ServerOffset, alignment.OffsetSec);
Assert.Equal(MatchTier.Runtime, alignment.Tier);
Assert.Null(alignment.Score);
Assert.Null(alignment.OffsetFrames);
}
// Short media is the same fallback, reached through JR-044 rather than
// through a missing string: both sides have a perfectly valid signature
// and it is still the runtime that decides.
var shortMedia = ManifestAligner.Resolve(
GoldenSignature.Value,
GoldenSignature.Value,
119.0,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.Server, shortMedia.Source);
Assert.Equal(ServerOffset, shortMedia.OffsetSec);
// A `v2:` signature from a future producer is *un-comparable*, not a
// mismatch (JR-045). Reporting it as one would tell the user their audio
// disagrees with the manifest when all that happened is the producer
// moved ahead of this build.
var futureProducer = ManifestAligner.Resolve(
GoldenSignature.Value,
"v2:" + GoldenSignature.Value[AudioSignature.VersionPrefix.Length..],
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.Server, futureProducer.Source);
Assert.Equal(ServerOffset, futureProducer.OffsetSec);
}
// UT-056
[Fact]
public void TwoSignaturesThatDoNotMatch_AreRecorded_ButStillDoNotBreakTheFetch()
{
// The strongest available hint that a manifest describes different
// content. It is not treated as a failure — the audio may legitimately
// differ, a different language track being the obvious case — but it is
// not discarded either.
var alignment = ManifestAligner.Resolve(
GoldenSignature.Value,
PseudoRandomSignature(1288, seed: 4242),
FeatureRuntime,
FeatureRuntime,
MatchTier.Runtime,
ServerOffset);
Assert.Equal(AlignmentSource.LocalMismatch, alignment.Source);
// The manifest is still stored, on the server's terms.
Assert.Equal(ServerOffset, alignment.OffsetSec);
Assert.Equal(MatchTier.Runtime, alignment.Tier);
}
// UT-057
[Fact]
public void AMismatchOutranksTheTier_InTheCaveatShownToTheUser()
{
// A `runtime`-tier match normally needs no caveat at all, so without
// this the strongest warning available would be the one never shown.
var mismatch = new TruthAlignment { Source = AlignmentSource.LocalMismatch };
var caveat = ManifestConverter.DescribeCaveat(MatchTier.Runtime, 0.0, mismatch);
Assert.NotNull(caveat);
Assert.Contains("does not match", caveat, StringComparison.Ordinal);
// It outranks `loose` too, which would otherwise have claimed the slot
// with the weaker statement — that the runtimes differ, not the audio.
var looseCaveat = ManifestConverter.DescribeCaveat(MatchTier.Loose, 0.0, mismatch);
Assert.Contains("does not match", looseCaveat!, StringComparison.Ordinal);
// A clean local alignment keeps the existing behaviour: a shift is
// explained, an aligned match needs nothing.
var local = new TruthAlignment { Source = AlignmentSource.Local };
Assert.Contains("shifted by", ManifestConverter.DescribeCaveat(MatchTier.Audio, 9.29, local)!, StringComparison.Ordinal);
Assert.Null(ManifestConverter.DescribeCaveat(MatchTier.Audio, 0.0, local));
}
private static string Signature(byte[] source, int start, int count)
=> AudioSignature.VersionPrefix + Convert.ToBase64String(source, start, count);
private static string PseudoRandomSignature(int frames, int seed)
{
var bytes = new byte[frames];
var state = (uint)seed;
for (var i = 0; i < frames; i++)
{
state = (state * 1664525u) + 1013904223u;
bytes[i] = (byte)((((state >> 16) % AudioSignature.NumBands) << 2) | ((state >> 8) & 0x03));
}
return AudioSignature.VersionPrefix + Convert.ToBase64String(bytes);
}
}