using System;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
///
/// Aligns a freshly fetched manifest to the local file before its windows are
/// stored.
///
///
/// Why the client re-derives an offset the server already sent. The
/// server has never seen the local file. Its offset is a claim about a runtime
/// it was told, so it can only ever be a runtime-difference inference. A local
/// alignment compares the manifest's own audio signature against the file the
/// windows will actually be drawn over, which is the authoritative comparison —
/// and it needs no round trip, so no signature leaves the instance. Where the
/// two disagree, the local one wins.
///
/// This is what jRay's specification means by matching being "a consumer
/// concern": the server never rewrites a manifest, so one stored manifest serves
/// every trim of the same cut, and each client shifts it to its own timebase
/// (JR-030).
///
///
/// Degradation, not failure. Signatures switched off, an item under the
/// 120 s window, a manifest with no signature, a missing FFmpeg binary, a decode
/// error, or two signatures that simply do not match — every one of these falls
/// back to the server's offset. A signature is an enhancement to cut matching; a
/// missing one costs a tier and must never be able to break a fetch.
///
///
// TRACES: JR-047 | SR-003
public class ManifestAligner
{
private readonly AudioSignatureService _signatures;
private readonly ILogger _logger;
///
/// Initializes a new instance of the class.
///
/// Computes the local file's audio signature.
/// Logger.
public ManifestAligner(AudioSignatureService signatures, ILogger logger)
{
_signatures = signatures;
_logger = logger;
}
///
/// Decides which offset to apply, given a local signature that has already
/// been computed.
///
///
/// Split from so the decision is testable without
/// an FFmpeg binary or a media file: everything interesting happens here,
/// and the caller only supplies the two strings.
///
/// The local file's signature, or null.
/// The manifest's signature, or null.
/// Local file runtime, in seconds.
/// Runtime the manifest records, in seconds.
/// The tier the server reported.
/// The offset the server reported.
/// What to apply, and how it was decided.
public static TruthAlignment Resolve(
string? localSignature,
string? manifestSignature,
double localRuntimeSec,
double manifestRuntimeSec,
MatchTier serverTier,
double serverOffsetSec)
{
var alignment = new TruthAlignment
{
Source = AlignmentSource.Server,
Tier = serverTier,
OffsetSec = serverOffsetSec,
ServerTier = serverTier,
ServerOffsetSec = serverOffsetSec,
LocalSignature = localSignature,
};
// "No alignment was possible" and "the audio does not match" are very
// different things to tell someone, and `Compare` returns null for both.
// A comparison is only possible when both items clear the 120 s window
// (JR-044) and both signatures parse as v1 (JR-045) — a `v2:` signature
// from a future producer is un-comparable, not a mismatch. Separating
// them here is what keeps a 90-second extra from being reported as
// content that disagrees with its own manifest.
var comparable = localSignature is not null
&& manifestSignature is not null
&& localRuntimeSec >= AudioSignature.WindowSec
&& manifestRuntimeSec >= AudioSignature.WindowSec
&& AudioSignatureMatcher.TryParseFrames(localSignature) is not null
&& AudioSignatureMatcher.TryParseFrames(manifestSignature) is not null;
if (!comparable)
{
return alignment;
}
var match = AudioSignatureMatcher.Compare(
localSignature, manifestSignature, localRuntimeSec, manifestRuntimeSec);
if (match is null)
{
// Both sides had a signature and they did not align at any tier. The
// server's offset still applies — the audio may legitimately differ,
// and a signature must not break a fetch — but this is the strongest
// available hint that the manifest describes different content, so
// it is recorded rather than silently discarded.
alignment.Source = AlignmentSource.LocalMismatch;
return alignment;
}
alignment.Source = AlignmentSource.Local;
alignment.Tier = match.Value.Tier;
alignment.OffsetSec = match.Value.OffsetSec;
alignment.Score = match.Value.Score;
alignment.OffsetFrames = match.Value.OffsetFrames;
return alignment;
}
///
/// Computes the local file's signature if it can, then resolves the
/// alignment.
///
/// Path of the local media file.
/// Local file runtime, in seconds.
/// The fetched manifest.
/// The tier the server reported.
/// The offset the server reported.
/// Whether signatures are enabled in configuration.
/// Cancellation token.
/// What to apply, and how it was decided.
public async Task AlignAsync(
string mediaPath,
double localRuntimeSec,
Jmanifest manifest,
MatchTier serverTier,
double serverOffsetSec,
bool computeSignatures,
CancellationToken cancellationToken)
{
ArgumentNullException.ThrowIfNull(manifest);
var manifestSignature = manifest.Cut?.AudioSignature;
string? localSignature = null;
// The decode is the expensive half, so it is skipped outright when it
// could not change the answer: no manifest signature to compare against
// means no local alignment is possible.
if (computeSignatures && manifestSignature is not null && !string.IsNullOrEmpty(mediaPath))
{
localSignature = await _signatures
.ComputeAsync(mediaPath, localRuntimeSec, cancellationToken)
.ConfigureAwait(false);
}
var alignment = Resolve(
localSignature,
manifestSignature,
localRuntimeSec,
manifest.Cut?.RuntimeSec ?? 0.0,
serverTier,
serverOffsetSec);
switch (alignment.Source)
{
case AlignmentSource.Local:
_logger.LogInformation(
"Local audio alignment for {Path}: score {Score:F3}, {Frames} frames, offset {Offset:F3}s at tier {Tier} (server said {ServerOffset:F3}s at {ServerTier})",
mediaPath,
alignment.Score,
alignment.OffsetFrames,
alignment.OffsetSec,
alignment.Tier,
alignment.ServerOffsetSec,
alignment.ServerTier);
break;
case AlignmentSource.LocalMismatch:
_logger.LogWarning(
"Audio signatures for {Path} did not align with the fetched manifest; applying the server's {Offset:F3}s at {Tier}. This may be a different cut, a different language track, or a heavy re-encode.",
mediaPath,
alignment.OffsetSec,
alignment.Tier);
break;
default:
_logger.LogDebug(
"No local alignment for {Path}; applying the server's {Offset:F3}s at {Tier}",
mediaPath,
alignment.OffsetSec,
alignment.Tier);
break;
}
return alignment;
}
}