Three routes now deliver truth data -- a sidecar, a worker push, and a fetched
manifest -- and once stored they were indistinguishable. The truth file records
nothing about how it arrived, so a locally computed sidecar and a loose-tier
manifest from a third-party server looked identical to every reader, despite
making claims of very different strength about the same item.
TruthProvenance records source, server, match tier, applied offset and caveat.
GET /Items/{itemId}/Provenance serves it, and JR-036's loose-tier caveat now
has somewhere to come from.
Two decisions carried in the code rather than assumed:
Provenance is stored BESIDE the truth file, never 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. UT-026 pins it by asserting the stored truth JSON
contains no provenance keys.
The applied offset is recorded because it is otherwise unrecoverable. Once
JR-030 shifts every window the timings look native, and nothing else would say
they had been shifted -- which matters when diagnosing an overlay that is
consistently a few seconds out.
A sidecar's provenance is derived rather than stored: it is local, and its
timestamp is the file's own. Precedence resolves through the same rule as
GetTruthAsync, because resolving it twice by different rules is how the two
would drift.
Fourth mutation check: stopping Delete from removing provenance fails UT-027
alone -- a stale record would otherwise outlive its claim and describe data the
next fetch had already replaced.
TRACES: UT-024, UT-025, UT-026, UT-027, UT-028 | JR-010
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
201 lines
7.3 KiB
C#
201 lines
7.3 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 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="logger">Logger.</param>
|
|
public ManifestController(
|
|
ILibraryManager libraryManager,
|
|
IManifestExchangeClient exchange,
|
|
IManagedTruthStore truthStore,
|
|
ILogger<ManifestController> logger)
|
|
{
|
|
_libraryManager = libraryManager;
|
|
_exchange = exchange;
|
|
_truthStore = truthStore;
|
|
_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" });
|
|
}
|
|
|
|
// 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 provenance = new TruthProvenance
|
|
{
|
|
Source = TruthSource.Fetched,
|
|
ServerUrl = outcome.ServerUrl,
|
|
MatchTier = outcome.Tier,
|
|
OffsetSec = outcome.OffsetSec,
|
|
Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec),
|
|
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)",
|
|
itemId,
|
|
outcome.ServerUrl,
|
|
outcome.Tier,
|
|
outcome.OffsetSec);
|
|
|
|
return Ok(new ManifestFetchResult
|
|
{
|
|
ServerUrl = outcome.ServerUrl,
|
|
Match = outcome.Tier.ToString().ToLowerInvariant(),
|
|
OffsetSec = outcome.OffsetSec,
|
|
ActorCount = truth.Actors.Count,
|
|
Caveat = ManifestConverter.DescribeCaveat(outcome.Tier, outcome.OffsetSec),
|
|
});
|
|
}
|
|
|
|
/// <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 };
|
|
}
|
|
}
|