diff --git a/Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs b/Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs new file mode 100644 index 0000000..e8c27e3 --- /dev/null +++ b/Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs @@ -0,0 +1,214 @@ +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); + } +} diff --git a/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs b/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs index 97251ac..b315781 100644 --- a/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs +++ b/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs @@ -31,6 +31,7 @@ public class ManifestController : ControllerBase private readonly ILibraryManager _libraryManager; private readonly IManifestExchangeClient _exchange; private readonly IManagedTruthStore _truthStore; + private readonly ManifestAligner _aligner; private readonly ILogger _logger; /// @@ -39,16 +40,19 @@ public class ManifestController : ControllerBase /// Library manager. /// Manifest exchange client. /// Managed truth store. + /// Aligns a fetched manifest to the local file. /// Logger. public ManifestController( ILibraryManager libraryManager, IManifestExchangeClient exchange, IManagedTruthStore truthStore, + ManifestAligner aligner, ILogger logger) { _libraryManager = libraryManager; _exchange = exchange; _truthStore = truthStore; + _aligner = aligner; _logger = logger; } @@ -102,35 +106,53 @@ public class ManifestController : ControllerBase return NotFound(new { error = "no configured server had an acceptable manifest" }); } + // Align before storing. The server has never seen this file, so where a + // local audio alignment is possible it supersedes the offset the server + // sent — and it needs no round trip, so no signature leaves the + // instance (JR-047). + var alignment = await _aligner.AlignAsync( + item.Path ?? string.Empty, + item.RunTimeTicks is { } ticks ? TimeSpan.FromTicks(ticks).TotalSeconds : 0.0, + outcome.Manifest, + outcome.Tier, + outcome.OffsetSec, + config.ComputeAudioSignatures, + cancellationToken) + .ConfigureAwait(false); + + var caveat = ManifestConverter.DescribeCaveat(alignment.Tier, alignment.OffsetSec, alignment); + // The offset is applied here, once, so the stored truth is always in the // local file's own timebase and no reader needs offset awareness. - var truth = ManifestConverter.ToTruthFile(outcome.Manifest, outcome.OffsetSec, item.Path ?? string.Empty); + var truth = ManifestConverter.ToTruthFile(outcome.Manifest, alignment.OffsetSec, item.Path ?? string.Empty); var provenance = new TruthProvenance { Source = TruthSource.Fetched, ServerUrl = outcome.ServerUrl, - MatchTier = outcome.Tier, - OffsetSec = outcome.OffsetSec, - Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec), + MatchTier = alignment.Tier, + OffsetSec = alignment.OffsetSec, + Alignment = alignment, + Caveat = caveat, RecordedAt = DateTime.UtcNow, }; await _truthStore.SaveAsync(itemId, truth, provenance, cancellationToken).ConfigureAwait(false); _logger.LogInformation( - "Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s)", + "Stored manifest for {ItemId} from {Server} at tier {Tier} (offset {Offset}s, aligned by {Source})", itemId, outcome.ServerUrl, - outcome.Tier, - outcome.OffsetSec); + alignment.Tier, + alignment.OffsetSec, + alignment.Source); return Ok(new ManifestFetchResult { ServerUrl = outcome.ServerUrl, - Match = outcome.Tier.ToString().ToLowerInvariant(), - OffsetSec = outcome.OffsetSec, + Match = alignment.Tier.ToString().ToLowerInvariant(), + OffsetSec = alignment.OffsetSec, ActorCount = truth.Actors.Count, - Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec), + Caveat = caveat, }); } diff --git a/Jellyfin.Plugin.JRay/Models/AlignmentSource.cs b/Jellyfin.Plugin.JRay/Models/AlignmentSource.cs new file mode 100644 index 0000000..ac717e3 --- /dev/null +++ b/Jellyfin.Plugin.JRay/Models/AlignmentSource.cs @@ -0,0 +1,42 @@ +namespace Jellyfin.Plugin.JRay.Models; + +/// +/// Which comparison produced the offset that was applied to a fetched manifest. +/// +/// +/// Recorded because the two are not equally strong evidence. The server has +/// never seen the local file, so its offset is at best a runtime-difference +/// inference; a local alignment compares the manifest's own audio signature +/// against the file the windows will actually be drawn over. +/// +public enum AlignmentSource +{ + /// + /// The server's offset was applied — no local alignment was possible. + /// + /// + /// Either signatures are switched off, the item is under the 120 s window, + /// the manifest carried no signature, or the decode failed. A signature is + /// an enhancement, so every one of those degrades to this rather than + /// failing the fetch. + /// + Server = 0, + + /// + /// A local audio alignment was computed and its offset was applied. + /// + Local = 1, + + /// + /// A local alignment was attempted and the two signatures did not match at + /// any tier; the server's offset was applied and the disagreement recorded. + /// + /// + /// Deliberately not a failure. The audio may legitimately differ — a + /// different language track, a heavy re-encode — and a signature must never + /// be able to break a fetch. But it is the strongest available hint that a + /// manifest describes different content, so it is surfaced as a caveat + /// rather than discarded. + /// + LocalMismatch = 2, +} diff --git a/Jellyfin.Plugin.JRay/Models/TruthAlignment.cs b/Jellyfin.Plugin.JRay/Models/TruthAlignment.cs new file mode 100644 index 0000000..615c9fa --- /dev/null +++ b/Jellyfin.Plugin.JRay/Models/TruthAlignment.cs @@ -0,0 +1,74 @@ +using System.Text.Json.Serialization; +using Jellyfin.Plugin.JRay.Configuration; + +namespace Jellyfin.Plugin.JRay.Models; + +/// +/// How a fetched manifest was aligned to the local file, recorded alongside the +/// truth data. +/// +/// +/// The offset is applied once, at store time (JR-030), after which the stored +/// windows look native and nothing would say they had been shifted. This block +/// is what makes that reconstructable: which comparison produced the offset, how +/// strong it was, and the local file's own signature, so a later fetch can +/// re-align without decoding the media again. +/// +// TRACES: JR-047 | SR-003 +public class TruthAlignment +{ + /// Gets or sets which comparison produced the applied offset. + [JsonPropertyName("source")] + public AlignmentSource Source { get; set; } + + /// Gets or sets the tier that was applied. + [JsonPropertyName("tier")] + public MatchTier Tier { get; set; } + + /// Gets or sets the offset applied to every window, in seconds. + [JsonPropertyName("offset_sec")] + public double OffsetSec { get; set; } + + /// + /// Gets or sets the local alignment's score, or null when none was + /// computed. + /// + [JsonPropertyName("score")] + public double? Score { get; set; } + + /// + /// Gets or sets the recovered slide between the two analysis windows, in + /// frames, or null when no local alignment was computed. + /// + /// + /// Only part of : the rest comes from the two windows + /// being anchored at different points when the runtimes differ. + /// + [JsonPropertyName("offset_frames")] + public int? OffsetFrames { get; set; } + + /// + /// Gets or sets the local file's own v1: audio signature, or + /// null when none was computed. + /// + /// + /// Kept so a later fetch can align against a new manifest without running + /// FFmpeg over the media again — the expensive half of the operation, and + /// the reason signatures are opt-in. It never leaves the instance: it is + /// stored beside the truth file, and contribution strips provenance + /// entirely (JR-034). + /// + [JsonPropertyName("local_signature")] + public string? LocalSignature { get; set; } + + /// + /// Gets or sets the offset the server claimed, retained even when a local + /// alignment superseded it. + /// + [JsonPropertyName("server_offset_sec")] + public double ServerOffsetSec { get; set; } + + /// Gets or sets the tier the server claimed. + [JsonPropertyName("server_tier")] + public MatchTier ServerTier { get; set; } +} diff --git a/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs b/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs index d5cd5bf..21890ca 100644 --- a/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs +++ b/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs @@ -74,6 +74,18 @@ public class TruthProvenance [JsonPropertyName("offset_sec")] public double OffsetSec { get; set; } + /// + /// Gets or sets how the applied offset was arrived at, or null for local + /// sources and for fetches made before alignment was recorded. + /// + /// + /// says what was applied; this says why, and keeps + /// the local file's own signature so a later fetch can re-align without + /// decoding the media again (JR-047). + /// + [JsonPropertyName("alignment")] + public TruthAlignment? Alignment { get; set; } + /// /// Gets or sets a human-readable caveat to surface with the overlay, or /// null when the claim needs none. diff --git a/Jellyfin.Plugin.JRay/ServiceRegistrator.cs b/Jellyfin.Plugin.JRay/ServiceRegistrator.cs index 0d6e35c..caa5080 100644 --- a/Jellyfin.Plugin.JRay/ServiceRegistrator.cs +++ b/Jellyfin.Plugin.JRay/ServiceRegistrator.cs @@ -23,9 +23,12 @@ public class ServiceRegistrator : IPluginServiceRegistrator // retried once per item (JR-037). serviceCollection.AddSingleton(); - // Registered concretely: the signature is computed, not yet consumed, so - // there is no second implementation for an interface to abstract over - // and nothing to gain from inventing one (JR-042). + // Registered concretely: there is no second implementation for an + // interface to abstract over and nothing to gain from inventing one + // (JR-042). The aligner consumes the signature on the fetch path, so a + // manifest is checked against the local file before its windows are + // stored (JR-047). serviceCollection.AddSingleton(); + serviceCollection.AddSingleton(); } } diff --git a/Jellyfin.Plugin.JRay/Services/ManifestAligner.cs b/Jellyfin.Plugin.JRay/Services/ManifestAligner.cs new file mode 100644 index 0000000..a640ddb --- /dev/null +++ b/Jellyfin.Plugin.JRay/Services/ManifestAligner.cs @@ -0,0 +1,205 @@ +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; + } +} diff --git a/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs b/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs index 2ea8922..7de7843 100644 --- a/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs +++ b/Jellyfin.Plugin.JRay/Services/ManifestConverter.cs @@ -118,9 +118,19 @@ public static class ManifestConverter /// /// The tier achieved. /// The offset applied. + /// How the offset was arrived at, when known. /// A caveat string, or null when the match needs no explanation. - public static string? DescribeCaveat(MatchTier tier, double offsetSec) + public static string? DescribeCaveat(MatchTier tier, double offsetSec, TruthAlignment? alignment = null) { + // Ranked before the tier, because it is the stronger statement: the + // server's tier says the runtimes are compatible, while a signature + // mismatch says the audio itself is not. The second outranks the first. + if (alignment?.Source == AlignmentSource.LocalMismatch) + { + return "This file's audio does not match the fetched manifest — it may be a different cut, " + + "a different language track, or a heavy re-encode. Timings may be wrong."; + } + if (tier == MatchTier.Loose) { return "Matched loosely — the runtime differs from this server's copy, so timings may drift."; diff --git a/SPEC.md b/SPEC.md index 63223ed..cfa20d9 100644 --- a/SPEC.md +++ b/SPEC.md @@ -816,10 +816,39 @@ offsets compare a handful of frames, where a chance agreement scores 1.0 and beats the true alignment. It never binds on the real case — two full-length signatures still overlap by 688 frames at the widest offset. -**Gap:** the signature is computed and can now be read, but nothing yet *calls* -either: `ComputeAudioSignatures` still gates nothing, no fetch path attaches a -signature or consults the matcher, and no contribution carries one. That wiring -belongs to the fetch and contribute paths (JR-031, JR-034), not here. +JR-047 connects it. `ManifestAligner` runs on the fetch path, before anything is +stored: it computes the local file's signature, compares it against the one the +manifest carries, and **applies the local offset in preference to the server's**. +The server has never seen this file — its offset is a runtime-difference +inference at best — and the comparison needs no round trip, so no signature +leaves the instance. `ComputeAudioSignatures` now gates that decode. + +Everything that can go wrong degrades to the server's offset rather than +refusing: signatures off, no manifest signature, media under the window, a `v2:` +producer, a missing binary, a decode error. A signature is an enhancement to cut +matching and must never be able to break a fetch. A *genuine* disagreement — +both signatures valid, both items long enough, best alignment still under 0.60 — +is recorded and surfaced as a caveat that outranks the tier's own, since "the +audio does not match" is a stronger statement than "the runtimes differ"; the +manifest is still stored, because the audio may legitimately differ. + +"Un-comparable" and "does not match" are kept distinct. A 90-second extra is not +content disagreeing with its manifest, and reporting it as such would be worse +than saying nothing. + +The applied offset, the score, the recovered slide, and the local file's own +signature are written beside the truth file (`TruthAlignment`) — the offset is +otherwise unrecoverable once the windows are shifted, and the stored signature +lets a later fetch align without decoding again. + +See [`docs/audio-alignment.md`](docs/audio-alignment.md) for the mechanism end to +end. + +**Gap:** contribution does not yet attach a signature (JR-034), and the stored +`local_signature` is written but not read back, so today every fetch decodes. +Server-side catalogue matching — `POST /manifests/search` and the `audio` tier — +is the server's UR-009 and is deliberately sequenced after signature coverage +accumulates; nothing here depends on it. --- diff --git a/docs/audio-alignment.md b/docs/audio-alignment.md new file mode 100644 index 0000000..9cecd64 --- /dev/null +++ b/docs/audio-alignment.md @@ -0,0 +1,288 @@ +# Audio signature and alignment — how it works + +How a manifest fetched from a public server is checked against, and shifted onto, +your own copy of a film. + +Requirements: `JR-042` … `JR-045`, `JR-047`. Construction is owned by +[`JRay-public-server/SPEC.md` §3](../../JRay-public-server/SPEC.md); this document +is the mechanism end to end. + +--- + +## The problem + +A manifest says "Peter Capaldi is on screen from 42:10 to 42:38". Your file may +not agree, because releases of the same film are trimmed differently — a +distributor logo here, a longer certificate card there. Thirty seconds of extra +head material makes every window in the manifest wrong by thirty seconds. + +The old defence was the runtime tier: accept a manifest only if the runtimes +agree within ±2 s. That rejects exactly the releases it should be fixing, and +accepts anything that happens to be the same length. + +A **content-derived signature** answers the question the exchange actually needs +— *do these timings apply to this media?* — and, when the answer is "yes but +shifted", says by how much. + +Deliberately, it does not answer *what file is this?* There is no file hash +anywhere in the system; the tier was withdrawn on legal grounds. The signature +identifies a **cut**, so two different encodes of the same edit agree. + +--- + +## The signature + +120 seconds from the **centre** of the media — the head and tail are the least +content-specific parts of a release, being logos and credits. + +``` +decode centre window runtime/2 ± 60 s +downmix mono +resample 11025 Hz +STFT 4096-sample frame, 1024-sample hop (~93 ms), Hann +band 300–3000 Hz, 32 logarithmically spaced bands +per frame peak band index (5 bits) + energy class (2 bits) + -> one byte, bit 7 always clear +result 1288 bytes, base64, prefixed "v1:" +``` + +Peak bins are used because they survive lossy re-encoding, loudness +normalisation and channel-layout differences, where absolute magnitudes do not. +Same principle as Chromaprint/AcoustID, but self-contained: no external service +is queried, so no lookup leaks which titles an instance holds. + +Bit 7 being always clear is not decoration. It is what makes an arbitrary byte +string *not* a valid signature, which is what the server validates on upload, and +what stops the field being usable as a payload channel. The length is fixed by +the construction rather than merely bounded, for the same reason — a caller +cannot choose it, so it cannot become a variable-size container. + +### Why the parameters are written down twice + +The specification's prose does not determine a byte stream. Two people +implementing "32 log-spaced bands, take the peak" will disagree on at least: + +- `float` or `double` — the fixture has frames whose two strongest bands are + within 1.3% of each other, so `float` is not sufficient; +- periodic Hann (`/N`) or symmetric (`/(N-1)`); +- whether a band's value is the **mean** or the **sum** of its magnitudes (sum + favours wide high bands over narrow low ones); +- which way an `argmax` breaks ties; +- whether the frame count is `1 + (n - 4096)/1024` or something that wobbles + with the resampler tail. + +Every one of those is pinned at the top of +[`AudioSignature.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs) and +matched in the C++ producer. A signature that differs in any parameter simply +does not match, which defeats the entire point of having one. + +### Two implementations, proven equal + +The extraction pipeline (C++, `IR-004`) computes this for files it processes +locally. The plugin (C#, `JR-042`) computes it for the files the pipeline never +sees. Both must agree exactly. + +That is a *checked* claim, not an aspiration. `fixtures/audio/` holds three files +byte-identical to the extraction repo's copies: + +| File | What it pins | +|---|---| +| `jray_audio_v1_tone.flac` | 120 s of tones stepping through all 32 bands, amplitudes walking a golden-ratio sequence so all four energy classes appear | +| `jray_audio_v1_golden.json` | The expected signature, the band→FFT-bin table, and checksums of the decoded PCM | +| `make_fixture.py` | Regenerates the media from plain arithmetic — no numpy, ports to any language in ~20 lines | + +The binding check regenerates the fixture PCM from `make_fixture.py`'s arithmetic +and verifies it against the recorded `s16le`/`f32le` checksums **before** making +any DSP claim. So it runs on a CI host with no codec at all, and a decode +divergence stays distinguishable from a DSP one. The two tests that drive real +FFmpeg self-skip without a binary — a check that skips is not a check, so it is +never the only cover for a claim. + +--- + +## Matching, and the offset + +Two signatures are compared by sliding one against the other: + +``` +for offset in -600 .. +600 frames: # ±56 s + score(offset) = fraction of overlapping frames whose peak band matches +best = argmax score +``` + +| Score | Meaning | +|---|---| +| `≥ 0.85` | Same cut. `audio` tier. The offset applies | +| `0.60 – 0.85` | Possibly the same cut, degraded audio. `loose` tier, surfaced as a caveat | +| `< 0.60` | Different content. No match | + +Only the **peak band** is scored. The energy class is the coarser and less +re-encoding-stable of the two fields, and the specification's rule names the peak +bin alone. + +Speed-differing releases (a PAL 4% speed-up) are not a constant offset and are +correctly rejected by the score threshold rather than mis-aligned. + +### The offset has two terms + +This is the part that is easy to get wrong. Both windows are centred on **their +own file's** midpoint, so when the runtimes differ the two windows do not start +at the same point in the content: + +``` +offset = (local_window_start - manifest_window_start) + slide × 1024/11025 + └────────── anchor difference ──────────┘ └──── recovered ────┘ +``` + +A release carrying 40 s of extra head material recovers **20 s from each term**. +Using the slide alone would be wrong by half the runtime difference on every +shifted release. The specification's pseudocode describes only the slide, because +it is written from the server's position, where both signatures are being +compared against one stored manifest. + +### One parameter that is not from the specification + +An alignment must overlap by at least **64 frames** (~6 s) before its score +counts. Without a floor the extreme offsets compare a handful of frames, where a +chance agreement scores 1.0 and beats the true alignment. It never binds on the +real case: two full-length signatures still overlap by 688 frames at the widest +offset. It is marked as a local addition in the code. + +--- + +## What happens when a manifest arrives + +`ManifestController.FetchItem` → `ManifestAligner.AlignAsync`, before anything is +stored: + +``` +1. Server returns a manifest, a tier, and an offset. +2. Does the manifest carry cut.audio_signature? no -> use the server's offset + Are signatures enabled in configuration? no -> use the server's offset +3. Decode this file's centre window, compute its signature. +4. Compare the two. + match -> apply the LOCAL offset and tier + no match -> apply the server's offset, record the disagreement + not comparable -> apply the server's offset +5. Apply the offset to every window, once, at store time. +6. Write the truth file, and the alignment beside it. +``` + +### Why the local answer wins + +**The server has never seen your file.** Its offset is a claim about a runtime it +was told — at best a runtime-difference inference. A local alignment compares the +manifest's own signature against the media the windows will actually be drawn +over, which is the authoritative comparison. It also needs no round trip, so no +signature ever leaves the instance. + +This is what the 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 onto its own timebase. + +### Degradation, never failure + +A signature is an enhancement to cut matching. A missing one costs a tier and +**must never be able to break a fetch**. Every one of these stores the manifest +on the server's terms: + +- signatures switched off in configuration (they are opt-in — the decode costs a + second or two of I/O per item); +- the item is under 120 s, so the window underflows and there is no signature to + compute (`JR-044`); +- the manifest carried no signature; +- the manifest's signature is `v2:` from a future producer — **refused, not + parsed** (`JR-045`), because scoring an unknown DSP chain as v1 would be a + confident wrong answer where declining is a correct one; +- no FFmpeg binary, no audio stream, or a decode error. + +### "Un-comparable" and "does not match" are different + +The distinction matters more than it looks. A 90-second extra is not content that +disagrees with its manifest — it is content that could not be compared. Reporting +the first as the second would show a user a scary warning about the wrong thing. + +A genuine mismatch — both signatures present, both valid, both items long enough, +and the best alignment still below 0.60 — is the strongest available hint that a +manifest describes different content. It is **still not a failure**: the audio may +legitimately differ, a different language track being the obvious case. So the +manifest is stored on the server's terms and the disagreement is surfaced as a +caveat, which outranks the tier's own caveat because it is the stronger +statement. + +--- + +## What gets stored + +The offset is applied **once**, at store time, so the stored windows are always +in your file's own timebase and no read path needs offset awareness (`JR-030`). +That makes the offset unrecoverable afterwards — the windows look native — which +is why the alignment is recorded beside the truth file: + +```json +"alignment": { + "source": "Local", + "tier": "Audio", + "offset_sec": 40.0, + "score": 0.97, + "offset_frames": 215, + "local_signature": "v1:AAAAA…", + "server_offset_sec": 0.0, + "server_tier": "Runtime" +} +``` + +`local_signature` is kept so a **later fetch aligns for free** — the decode is +the expensive half and the reason signatures are opt-in. It never leaves the +instance: provenance is stored beside the truth file, not inside it, and +contribution strips provenance entirely (`JR-034`). + +The truth file itself is untouched by any of this. Injecting fields would mean +the bytes served back are not the bytes the producer wrote, which is the property +`JR-004` turns on. + +--- + +## Where the code is + +| Concern | File | +|---|---| +| The DSP | [`Services/AudioSignature.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs) | +| The decode | [`Services/AudioSignatureService.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs) | +| Reading and matching | [`Services/AudioSignatureMatcher.cs`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs) | +| The fetch-path decision | [`Services/ManifestAligner.cs`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs) | +| What is recorded | [`Models/TruthAlignment.cs`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs) | +| Server-side validation | `JRay-public-server/src/validate.rs`, `validate_audio_signature` | +| C++ producer | `scene-actor-extraction/src/audio_signature.{hpp,cpp}` | + +Tests: `AudioSignatureTests` (UT-038 … UT-044), `AudioSignatureMatcherTests` +(UT-045 … UT-052), `ManifestAlignerTests` (UT-053 … UT-057). + +## Verifying the cross-repo claim by hand + +```sh +# C# side +cd jRay && dotnet test Jellyfin.Plugin.JRay.Tests/Jellyfin.Plugin.JRay.Tests.csproj + +# C++ side, against the same fixture +cd scene-actor-extraction/build && python3 -c " +import json, sae_audio +g = json.load(open('../tests/fixtures/audio/jray_audio_v1_golden.json')) +print(sae_audio.compute_signature('../tests/fixtures/audio/jray_audio_v1_tone.flac') == g['signature']) +" + +# and that the fixtures really are the same bytes +md5sum jRay/Jellyfin.Plugin.JRay.Tests/fixtures/audio/jray_audio_v1_golden.json \ + scene-actor-extraction/tests/fixtures/audio/jray_audio_v1_golden.json +``` + +## Not built yet + +- **Matching against a server's catalogue.** `POST /manifests/search` and the + `audio` tier server-side are `UR-009`, still in progress. The sequencing is + deliberate — accumulate signatures first, enable matching once coverage is + useful. Nothing here depends on it: alignment works from the signature the + manifest already carries. +- **Contribution.** Uploads do not yet attach a signature (`JR-034`). +- **Re-using the stored signature.** It is written but not yet read back on a + second fetch, so today every fetch decodes. diff --git a/docs/requirements.md b/docs/requirements.md index 9483f44..7cca3b7 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -72,6 +72,11 @@ Tag code with `// TRACES: JR-012 | SR-002`. | UT-050 | Identical signatures score 1.0 at offset 0 and reach the `audio` tier | JR-044 | **Passing** | | UT-051 | **A shifted release recovers its offset** rather than failing to match — the case the feature exists for | JR-044 | **Passing** | | UT-052 | Unrelated content yields **no match at all**, and runtime skew contributes its window-anchor term to the offset | JR-044 | **Passing** | +| UT-053 | **A local alignment supersedes the server's offset**, and the server's claim is retained rather than overwritten | JR-047 | **Passing** | +| UT-054 | The local signature is recorded, so a later fetch aligns without decoding the media again | JR-047 | **Passing** | +| UT-055 | Every unavailable local path — off, no manifest signature, neither, short media, **and a `v2:` producer** — falls back to the server rather than refusing | JR-047 | **Passing** | +| UT-056 | Two signatures that genuinely disagree are recorded as a mismatch and **still do not break the fetch** | JR-047 | **Passing** | +| UT-057 | A mismatch **outranks the tier** in the caveat shown to the user — a `runtime` match would otherwise show nothing at all | JR-047 | **Passing** | All execute and pass. UT-043 and UT-044 are the two that need an FFmpeg binary, which the plugin gets from Jellyfin at run time and a bare CI container may not @@ -216,18 +221,24 @@ preserved. | JR-040 | The config page states plainly that **each configured server multiplies the exposure** | **PR-005** | Medium | Planned | | JR-041 | The plugin never fetches, stores, or transmits gallery data — reference faces or embeddings. It has no gallery code path at all | **SR-005** | High | Done | -## Audio signature (JR-042 … JR-045) +## Audio signature (JR-042 … JR-045, JR-047) Mirror-image of extraction `IR-004`/`IR-005`/`IR-007`/`IR-008`. Both producers must agree **bit-for-bit**, so each obligation is stated on both sides rather than assumed to be inherited. +JR-042 … JR-045 are the signature itself, produced and read. **JR-047 is what +uses it**: without a consumer on the fetch path the other four are a fingerprint +nothing ever fingerprints. See [`audio-alignment.md`](audio-alignment.md) for the +end-to-end mechanism. + | ID | Requirement | Traces to | Priority | Status | |---|---|---|---|---| | JR-042 | Compute the signature **exactly** per server spec §3, using the FFmpeg binary Jellyfin already ships via `IMediaEncoder.EncoderPath` — no new dependency | SR-003 | Medium | **Done** (UT-039…044) — `AudioSignature` is the DSP, `AudioSignatureService` the decode; FFmpeg is invoked as a child process for decode, downmix and resample, and nothing was added to the project's dependencies | | JR-043 | Golden-vector fixture **shared with the extraction repo**, proving the two implementations are bit-exact | SR-003 | High | **Done** (UT-038, UT-039) — `fixtures/audio/` holds the extraction repo's three files byte-identically; the C# signature equals the recorded vector exactly | | JR-044 | Media shorter than 120 s: emit no signature and apply no sync offset — identical rule in both producers | SR-003 | Low | **Done** (UT-045, UT-046, UT-050…052) — the producer half was already in `AudioSignatureService`; the consumer half needed a reader, so `AudioSignatureMatcher` implements the §3 slide and declines an offset outright below the window. The boundary is asserted on one file at 119.999 s and 120.000 s | | JR-045 | Emit and honour the signature's own `v1:` prefix, so a DSP change is detectable rather than silently non-matching | SR-003 | Low | **Done** (UT-047…049) — `AudioSignatureMatcher.TryParseFrames` refuses any prefix but `v1:`, and refuses malformed or structurally invalid payloads, so a future producer's `v2:` drops the item to the runtime tier instead of scoring as if it were understood | +| JR-047 | **A fetched manifest is aligned against the local file before its windows are stored**, and the alignment is recorded beside the truth data | SR-003 | High | **Done** (UT-053…057) — `ManifestAligner` runs on the fetch path. A local alignment supersedes the server's offset, since the server has never seen this file; every unavailable path degrades to the server's offset rather than refusing, and a genuine signature disagreement is recorded and surfaced as a caveat without failing the fetch | ## Human-in-the-loop association (JR-046) @@ -355,6 +366,7 @@ framework reference and leans on `RollForward` to reach the 10.0 runtime. | JR-043 | **T1** | Signature matches the shared golden vector **bit-for-bit** | The fixture PCM is regenerated from `make_fixture.py` and checked against the recorded decode checksums first, so the check binds on a host with no codec and a decode divergence is distinguishable from a DSP one | | JR-044 | T1 | Media < 120 s yields no signature and no offset | Exactly 120 s — the boundary both repos must agree on | | JR-045 | T1 | `v1:` emitted; an unknown prefix is refused, not parsed | `v2:` signature from a future producer | +| JR-047 | **T1** | A fetched manifest is aligned locally before storage, and the alignment is recorded | **Every way the local path can be unavailable must degrade to the server's offset, never refuse** — signatures off, no manifest signature, media under the window, a `v2:` producer. Distinguish those from a genuine mismatch: a 90 s extra is not content that disagrees with its manifest, and telling a user it is would be worse than saying nothing | | JR-046 | T2 + **T4** | *Assertions deferred* — recording an association and persisting it is T2; the review UI itself is T4 | Cannot be written until the truth-file interface for unidentified presence is settled (system open question 2) and AR-021/AR-022 land | Three are worth singling out. **JR-021** and **JR-041** are static checks because diff --git a/docs/traceability.md b/docs/traceability.md index ed3000c..8fe5360 100644 --- a/docs/traceability.md +++ b/docs/traceability.md @@ -3,7 +3,7 @@ -**Generated:** 2026-07-31T14:19:46+00:00 +**Generated:** 2026-07-31T14:50:36+00:00 Denominators are read from [`requirements.md`](requirements.md) at run time, never hardcoded. Coverage counts a requirement only when it is tagged in source **and** has a verification tier this repo's CI host can execute (`T1, T2, static`). @@ -11,13 +11,13 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Metric | Value | |---|---| -| Source files scanned | 74 | -| TRACES tags found | 45 | +| Source files scanned | 78 | +| TRACES tags found | 48 | | EXCEPTION tags found | 0 | -| Requirements defined | 46 | -| Requirements covered | 37 | -| **Coverage** | **80.4%** (37/46) | -| Coverage of CI-executable scope | 84.1% (37/44) | +| Requirements defined | 47 | +| Requirements covered | 38 | +| **Coverage** | **80.9%** (38/47) | +| Coverage of CI-executable scope | 84.4% (38/45) | | Tagged but unexecuted in CI | 1 | | Orphan tags | 0 | @@ -25,9 +25,9 @@ Denominators are read from [`requirements.md`](requirements.md) at run time, nev | Type | Covered | Tagged but unexecuted | Defined | |---|---|---|---| -| JR | 37 | 1 | 46 | +| JR | 38 | 1 | 47 | -- **UT** tags present (separate taxonomy, not counted in coverage): UT-001, UT-002, UT-003, UT-004, UT-005, UT-007, UT-008, UT-009, UT-010, UT-011, UT-012, UT-013, UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-024, UT-025, UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037, UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044, UT-045, UT-046, UT-047, UT-048, UT-049, UT-050, UT-051, UT-052 +- **UT** tags present (separate taxonomy, not counted in coverage): UT-001, UT-002, UT-003, UT-004, UT-005, UT-007, UT-008, UT-009, UT-010, UT-011, UT-012, UT-013, UT-014, UT-015, UT-016, UT-017, UT-018, UT-019, UT-020, UT-021, UT-022, UT-023, UT-024, UT-025, UT-026, UT-027, UT-028, UT-029, UT-030, UT-031, UT-032, UT-033, UT-034, UT-035, UT-036, UT-037, UT-038, UT-039, UT-040, UT-041, UT-042, UT-043, UT-044, UT-045, UT-046, UT-047, UT-048, UT-049, UT-050, UT-051, UT-052, UT-053, UT-054, UT-055, UT-056, UT-057 - **PR** tags present (separate taxonomy, not counted in coverage): PR-001, PR-003, PR-004, PR-005, PR-006 - **SR** tags present (separate taxonomy, not counted in coverage): SR-001, SR-002, SR-003, SR-004, SR-005 @@ -110,6 +110,7 @@ _None._ | JR-044 | **Done** (UT-045, U… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs` | Media shorter than 120 s: emit no signature and apply no sync offset … | | JR-045 | **Done** (UT-047…04… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs`, `Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs` | Emit and honour the signature's own `v1:` prefix, so a DSP change is … | | JR-046 | **TBD** | T2, T4 | [system §4](../scripts/vendor/jray-proj… | untagged | - | Review UI for unidentified track clusters: show context crops, pick f… | +| JR-047 | **Done** (UT-053…05… | T1 | SR-003 | covered | `Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs`, `Jellyfin.Plugin.JRay/Models/TruthAlignment.cs`, `Jellyfin.Plugin.JRay/Services/ManifestAligner.cs` | **A fetched manifest is aligned against the local file before its win… | ## Detailed mapping @@ -384,6 +385,14 @@ _None._ - [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher` - [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests` +### JR-047 + +**Locations:** 3 + +- [`Jellyfin.Plugin.JRay/Models/TruthAlignment.cs:17`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs#L17) — `public class TruthAlignment` +- [`Jellyfin.Plugin.JRay/Services/ManifestAligner.cs:36`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs#L36) — `public class ManifestAligner` +- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests` + ### PR-001 **Locations:** 3 @@ -449,10 +458,11 @@ _None._ ### SR-003 -**Locations:** 11 +**Locations:** 13 - [`Jellyfin.Plugin.JRay/Controllers/TruthController.cs:28`](../Jellyfin.Plugin.JRay/Controllers/TruthController.cs#L28) — `public class TruthController : ControllerBase` - [`Jellyfin.Plugin.JRay/Models/Jmanifest.cs:21`](../Jellyfin.Plugin.JRay/Models/Jmanifest.cs#L21) — `public class Jmanifest` +- [`Jellyfin.Plugin.JRay/Models/TruthAlignment.cs:17`](../Jellyfin.Plugin.JRay/Models/TruthAlignment.cs#L17) — `public class TruthAlignment` - [`Jellyfin.Plugin.JRay/Models/TruthCut.cs:15`](../Jellyfin.Plugin.JRay/Models/TruthCut.cs#L15) — `public class TruthCut` - [`Jellyfin.Plugin.JRay/Models/TruthExtraction.cs:21`](../Jellyfin.Plugin.JRay/Models/TruthExtraction.cs#L21) — `public class TruthExtraction` - [`Jellyfin.Plugin.JRay/Models/TruthFile.cs:26`](../Jellyfin.Plugin.JRay/Models/TruthFile.cs#L26) — `public class TruthFile` @@ -460,6 +470,7 @@ _None._ - [`Jellyfin.Plugin.JRay/Services/AudioSignature.cs:49`](../Jellyfin.Plugin.JRay/Services/AudioSignature.cs#L49) — `public static class AudioSignature` - [`Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs:24`](../Jellyfin.Plugin.JRay/Services/AudioSignatureMatcher.cs#L24) — `public static class AudioSignatureMatcher` - [`Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs:39`](../Jellyfin.Plugin.JRay/Services/AudioSignatureService.cs#L39) — `public class AudioSignatureService` +- [`Jellyfin.Plugin.JRay/Services/ManifestAligner.cs:36`](../Jellyfin.Plugin.JRay/Services/ManifestAligner.cs#L36) — `public class ManifestAligner` - [`Jellyfin.Plugin.JRay/Services/ManifestConverter.cs:11`](../Jellyfin.Plugin.JRay/Services/ManifestConverter.cs#L11) — `public static class ManifestConverter` - [`Jellyfin.Plugin.JRay/Services/TruthSchema.cs:35`](../Jellyfin.Plugin.JRay/Services/TruthSchema.cs#L35) — `public static class TruthSchema` @@ -783,3 +794,33 @@ _None._ - [`Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs:26`](../Jellyfin.Plugin.JRay.Tests/AudioSignatureMatcherTests.cs#L26) — `public class AudioSignatureMatcherTests` +### UT-053 + +**Locations:** 1 + +- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests` + +### UT-054 + +**Locations:** 1 + +- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests` + +### UT-055 + +**Locations:** 1 + +- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests` + +### UT-056 + +**Locations:** 1 + +- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests` + +### UT-057 + +**Locations:** 1 + +- [`Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs:28`](../Jellyfin.Plugin.JRay.Tests/ManifestAlignerTests.cs#L28) — `public class ManifestAlignerTests` +