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