Files
jRay/Jellyfin.Plugin.JRay/Controllers/ManifestController.cs
T
dtourolleandClaude Opus 5 3d210b5bd3
🏗️ Build Plugin / build (push) Successful in 44s
Latest Release / latest-release (push) Successful in 40s
🧪 Test Plugin / test (push) Successful in 26s
Manifest fetch across the configured servers (JR-025 … JR-037)
Satisfies JRay-public-server UR-007. Servers are tried in configured order and
the first result clearing the configured tier wins; first-match rather than
best-match because querying every server for every item multiplies egress and
leaks the library to more parties, and the ordering already encodes which
source the admin prefers.

Every server is untrusted, including the pre-configured community one, so a
fetched manifest is re-validated against the same rules the server applies on
upload: envelope version refused if unknown, identifiers format-checked,
windows bounds-checked against the *local* file's runtime, belief bounded to
[0, 1], control and bidi characters refused in names. Responses are capped
while streaming rather than after buffering, since a hostile server can declare
any Content-Length it likes. HTTPS is required away from loopback. A failing
server is skipped with exponential backoff so one dead server cannot stall a
sweep.

The audio-tier offset is applied once, at store time, so stored truth is always
in the local file's own timebase and no read path needs offset awareness.
Windows are shifted, never reshaped — merging adjacent ones would answer "was a
face visible" rather than "was the actor present" (SR-002).

Also records why there is no `exact` tier, which was missing and led me to
re-add one. The file-hash tier is withdrawn on legal grounds: a TMDB id
discloses "some copy of this film", but an OpenSubtitles hash discloses "this
exact release", which turns a catalogue lookup into a release-identification
service and a server's database into a mapping from file fingerprints to the
instances holding them. The reason now lives on MatchTier and in SPEC.md §JR-036,
`TitleQuery` has no VideoHash property so there is nothing to send, and a test
asserts the enum has no Exact member — the spec had still listed `exact` as a
configurable tier, which is what made the removal look like an oversight.

42 tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

TRACES: JR-025, JR-027, JR-028, JR-029, JR-030, JR-031, JR-036, JR-037 | PR-005, PR-006
2026-07-31 10:03:49 +02:00

191 lines
7.0 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);
await _truthStore.SaveAsync(itemId, truth, 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 };
}
}