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 ILogger _logger; /// /// Initializes a new instance of the class. /// /// Library manager. /// Manifest exchange client. /// Managed truth store. /// Logger. public ManifestController( ILibraryManager libraryManager, IManifestExchangeClient exchange, IManagedTruthStore truthStore, ILogger logger) { _libraryManager = libraryManager; _exchange = exchange; _truthStore = truthStore; _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" }); } // 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), }); } /// /// 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 }; } }