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