Files
jRay/Jellyfin.Plugin.JRay/Models/TruthProvenance.cs
dtourolle 1e247c4c7d feat(audio): align a fetched manifest to the local file before storing
The signature had a producer and a reader but no consumer, so nothing
ever fingerprinted anything. `ManifestAligner` runs on the fetch path,
before the windows are stored.

A local alignment supersedes the server's offset. The server has never
seen this file — its offset is a runtime-difference inference at best,
while the local comparison is against the media the windows will actually
be drawn over. It also needs no round trip, so no signature leaves the
instance. This is what jRay's spec already meant by matching being a
consumer concern: the server never rewrites a manifest, so one stored
manifest serves every trim of the same cut.

The offset has two terms and only one is in the server's pseudocode. Both
windows are centred on their own file's midpoint, so unequal runtimes
start them at different absolute times; a release with 40 s of extra head
material recovers 20 s from the slide and 20 s from the anchor
difference. Using the slide alone is wrong by half the runtime difference
on every shifted release.

Degradation, never failure. Signatures off, no manifest signature, media
under the window, a `v2:` producer, a missing binary, a decode error —
each applies the server's offset rather than refusing, because a
signature is an enhancement to cut matching and must never break a fetch.

"Un-comparable" and "does not match" are kept distinct, which a test
caught: `Compare` returns null for both, and conflating them would report
a 90-second extra as content disagreeing with its own manifest. A genuine
disagreement is stored anyway — the audio may legitimately differ, a
different language track being the obvious case — and surfaced as a
caveat that outranks the tier's, since it is the stronger statement.

The applied offset, score, slide and the local file's own signature are
written beside the truth file: the offset is otherwise unrecoverable once
the windows are shifted, and the stored signature lets a later fetch
align without decoding again. Provenance is never injected into the truth
file, so the bytes served back stay the producer's (JR-004).

`docs/audio-alignment.md` documents the mechanism end to end.

TRACES: JR-047 | SR-003
2026-07-31 16:52:21 +02:00

111 lines
4.0 KiB
C#

using System;
using System.Text.Json.Serialization;
using Jellyfin.Plugin.JRay.Configuration;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// Where an item's truth data came from.
/// </summary>
public enum TruthSource
{
/// <summary>A <c>.jray.json</c> file beside the media, written locally.</summary>
Sidecar = 0,
/// <summary>Pushed over HTTP by a worker that cannot write beside the media.</summary>
Pushed = 1,
/// <summary>Fetched from a manifest server and converted to a truth file.</summary>
Fetched = 2,
}
/// <summary>
/// How an item's truth data was obtained, recorded alongside it.
/// </summary>
/// <remarks>
/// The three sources are not interchangeable. A locally computed sidecar and a
/// <c>loose</c>-tier manifest from a third-party server make claims of very
/// different strength about the same item, and once stored they are otherwise
/// indistinguishable — the truth file itself records nothing about how it
/// arrived.
///
/// <para>
/// This is stored <b>beside</b> the truth file rather than inside it. Injecting
/// fields would mean the bytes served back are not the bytes the producer wrote,
/// which is the property JR-004 turns on.
/// </para>
/// </remarks>
// TRACES: JR-010, JR-036 | PR-001
public class TruthProvenance
{
/// <summary>
/// Gets or sets which of the three routes delivered this truth data.
/// </summary>
[JsonPropertyName("source")]
public TruthSource Source { get; set; }
/// <summary>
/// Gets or sets the server a fetched manifest came from. Empty for the
/// local sources, whose origin is this instance.
/// </summary>
[JsonPropertyName("server_url")]
public string ServerUrl { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the cut-match tier a fetched manifest reached.
/// </summary>
/// <remarks>
/// Null for local sources: a sidecar or a push is about *this* file, so
/// there is no cut to match. The tier is what makes a fetched claim
/// interpretable — <c>loose</c> means "probably the same cut", which the UI
/// must surface rather than apply silently (JR-036).
/// </remarks>
[JsonPropertyName("match_tier")]
public MatchTier? MatchTier { get; set; }
/// <summary>
/// Gets or sets the offset, in seconds, applied to every window before
/// storage so the stored timings are in this file's own timebase (JR-030).
/// </summary>
/// <remarks>
/// Recorded because it is otherwise unrecoverable: once applied, the stored
/// windows look native, and nothing would say they had been shifted.
/// </remarks>
[JsonPropertyName("offset_sec")]
public double OffsetSec { get; set; }
/// <summary>
/// Gets or sets how the applied offset was arrived at, or null for local
/// sources and for fetches made before alignment was recorded.
/// </summary>
/// <remarks>
/// <see cref="OffsetSec"/> 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).
/// </remarks>
[JsonPropertyName("alignment")]
public TruthAlignment? Alignment { get; set; }
/// <summary>
/// Gets or sets a human-readable caveat to surface with the overlay, or
/// null when the claim needs none.
/// </summary>
[JsonPropertyName("caveat")]
public string? Caveat { get; set; }
/// <summary>
/// Gets or sets when this truth data was recorded, UTC.
/// </summary>
[JsonPropertyName("recorded_at")]
public DateTime RecordedAt { get; set; }
/// <summary>
/// Creates provenance for truth data produced on this instance.
/// </summary>
/// <param name="source">Either <see cref="TruthSource.Sidecar"/> or <see cref="TruthSource.Pushed"/>.</param>
/// <param name="recordedAt">When it was recorded, UTC.</param>
/// <returns>The provenance record.</returns>
public static TruthProvenance Local(TruthSource source, DateTime recordedAt)
=> new() { Source = source, RecordedAt = recordedAt };
}