Files
jRay/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs
T
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

223 lines
8.2 KiB
C#

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Jellyfin.Plugin.JRay.Configuration;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Common.Api;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Fetches actor-timeline manifests from the configured public servers.
/// </summary>
// TRACES: JR-025, JR-031 | PR-006
[ApiController]
[Authorize(Policy = Policies.RequiresElevation)]
[Route("Plugins/JRay")]
[Produces("application/json")]
public class ManifestController : ControllerBase
{
private readonly ILibraryManager _libraryManager;
private readonly IManifestExchangeClient _exchange;
private readonly IManagedTruthStore _truthStore;
private readonly ManifestAligner _aligner;
private readonly ILogger<ManifestController> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="ManifestController"/> class.
/// </summary>
/// <param name="libraryManager">Library manager.</param>
/// <param name="exchange">Manifest exchange client.</param>
/// <param name="truthStore">Managed truth store.</param>
/// <param name="aligner">Aligns a fetched manifest to the local file.</param>
/// <param name="logger">Logger.</param>
public ManifestController(
ILibraryManager libraryManager,
IManifestExchangeClient exchange,
IManagedTruthStore truthStore,
ManifestAligner aligner,
ILogger<ManifestController> logger)
{
_libraryManager = libraryManager;
_exchange = exchange;
_truthStore = truthStore;
_aligner = aligner;
_logger = logger;
}
/// <summary>
/// Resolves an item across the configured servers and stores the first
/// acceptable manifest.
/// </summary>
/// <param name="itemId">The library item id.</param>
/// <param name="cancellationToken">Cancellation token.</param>
/// <returns>What was fetched, and from where.</returns>
/// <response code="200">A manifest was stored.</response>
/// <response code="404">The item does not exist, or no server had a manifest for it.</response>
/// <response code="409">Manifest sharing is disabled in the plugin configuration.</response>
[HttpPost("Items/{itemId}/Fetch")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task<ActionResult<ManifestFetchResult>> FetchItem(
[FromRoute] Guid itemId,
CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.EnableManifestSharing)
{
// Off by default and opt-in: this is a network egress feature, so it
// never runs merely because an endpoint was called.
return Conflict(new { error = "manifest sharing is disabled" });
}
var item = _libraryManager.GetItemById(itemId);
if (item is null)
{
return NotFound();
}
var query = BuildQuery(item);
if (query is null)
{
return NotFound(new { error = "item has no TMDB or IMDB id to look up" });
}
var servers = config.Servers.ToList();
var outcome = item is Episode
? await _exchange.FetchEpisodeAsync(servers, config.MinimumMatchTier, query, cancellationToken)
.ConfigureAwait(false)
: await _exchange.FetchMovieAsync(servers, config.MinimumMatchTier, query, cancellationToken)
.ConfigureAwait(false);
if (outcome?.Manifest is null)
{
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, alignment.OffsetSec, item.Path ?? string.Empty);
var provenance = new TruthProvenance
{
Source = TruthSource.Fetched,
ServerUrl = outcome.ServerUrl,
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, aligned by {Source})",
itemId,
outcome.ServerUrl,
alignment.Tier,
alignment.OffsetSec,
alignment.Source);
return Ok(new ManifestFetchResult
{
ServerUrl = outcome.ServerUrl,
Match = alignment.Tier.ToString().ToLowerInvariant(),
OffsetSec = alignment.OffsetSec,
ActorCount = truth.Actors.Count,
Caveat = caveat,
});
}
/// <summary>
/// Per-server reachability and last error, for the configuration page.
/// </summary>
/// <returns>One entry per configured server, in configured order.</returns>
/// <response code="200">Status for each configured server.</response>
[HttpGet("Servers/Status")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<ServerStatus>> GetServerStatus()
{
var config = Plugin.Instance?.Configuration;
if (config is null)
{
return Ok(Array.Empty<ServerStatus>());
}
return Ok(_exchange.GetStatus(config.Servers.ToList()));
}
/// <summary>
/// Reads a provider id from an item, or null when it is absent.
/// </summary>
private static string? ProviderId(BaseItem? item, string provider) =>
item?.ProviderIds is { } ids && ids.TryGetValue(provider, out var value)
&& !string.IsNullOrWhiteSpace(value)
? value
: null;
/// <summary>
/// Builds the lookup query from an item's provider ids and measured runtime.
/// </summary>
private static TitleQuery? BuildQuery(BaseItem item)
{
var runtimeSec = item.RunTimeTicks.HasValue
? TimeSpan.FromTicks(item.RunTimeTicks.Value).TotalSeconds
: (double?)null;
if (item is Episode episode)
{
var series = episode.Series;
var seriesTmdb = ProviderId(series, "Tmdb");
if (string.IsNullOrEmpty(seriesTmdb))
{
return null;
}
return new TitleQuery
{
SeriesTmdbId = seriesTmdb,
Season = episode.ParentIndexNumber,
Episode = episode.IndexNumber,
RuntimeSec = runtimeSec,
};
}
var tmdb = ProviderId(item, "Tmdb");
var imdb = ProviderId(item, "Imdb");
if (string.IsNullOrEmpty(tmdb) && string.IsNullOrEmpty(imdb))
{
return null;
}
return new TitleQuery { TmdbId = tmdb, ImdbId = imdb, RuntimeSec = runtimeSec };
}
}