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;
///
/// Fetches actor-timeline manifests from the configured public servers.
///
// 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 _logger;
///
/// Initializes a new instance of the class.
///
/// 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;
}
///
/// Resolves an item across the configured servers and stores the first
/// acceptable manifest.
///
/// The library item id.
/// Cancellation token.
/// What was fetched, and from where.
/// A manifest was stored.
/// The item does not exist, or no server had a manifest for it.
/// Manifest sharing is disabled in the plugin configuration.
[HttpPost("Items/{itemId}/Fetch")]
[ProducesResponseType(StatusCodes.Status200OK)]
[ProducesResponseType(StatusCodes.Status404NotFound)]
[ProducesResponseType(StatusCodes.Status409Conflict)]
public async Task> 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,
});
}
///
/// Per-server reachability and last error, for the configuration page.
///
/// One entry per configured server, in configured order.
/// Status for each configured server.
[HttpGet("Servers/Status")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult> GetServerStatus()
{
var config = Plugin.Instance?.Configuration;
if (config is null)
{
return Ok(Array.Empty());
}
return Ok(_exchange.GetStatus(config.Servers.ToList()));
}
///
/// Reads a provider id from an item, or null when it is absent.
///
private static string? ProviderId(BaseItem? item, string provider) =>
item?.ProviderIds is { } ids && ids.TryGetValue(provider, out var value)
&& !string.IsNullOrWhiteSpace(value)
? value
: null;
///
/// Builds the lookup query from an item's provider ids and measured runtime.
///
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 };
}
}