using System; using System.Collections.Generic; using System.IO; using System.Linq; using Jellyfin.Data.Enums; using Jellyfin.Plugin.JRay.Models; using Jellyfin.Plugin.JRay.Services; using Jellyfin.Plugin.JRay.Services.Interfaces; 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; namespace Jellyfin.Plugin.JRay.Controllers; /// /// Lets a remote extraction worker discover which library items still need /// to be processed. /// [ApiController] [Route("Plugins/JRay/Tasks")] [Authorize(Roles = "Administrator")] public class TasksController : ControllerBase { private const int DefaultLimit = 10; private const int MaxLimit = 100; private readonly ILibraryManager _libraryManager; private readonly ITruthDataService _truthDataService; private readonly IMediaPolicyStore _policyStore; /// /// Initializes a new instance of the class. /// /// The Jellyfin library manager. /// The truth data service. /// The prioritise/ignore policy store. public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore) { _libraryManager = libraryManager; _truthDataService = truthDataService; _policyStore = policyStore; } /// /// Gets a random sample of movies/episodes that have no truth data yet. /// /// The maximum number of items to return (default 10, max 100). /// Up to items with no truth data, in random order. [HttpGet("Pending")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetPending([FromQuery] int limit = DefaultLimit) { var effectiveLimit = Math.Clamp(limit, 1, MaxLimit); var rules = _policyStore.GetRules(); var items = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode }, IsVirtualItem = false, Recursive = true, }); // Keep only items that still need truth data, then apply the policy: // drop anything ignored, and order prioritised items ahead of the rest. // Randomise within each tier so the backlog still spreads across workers. var pending = items .Where(item => MediaFileExists(item) && !_truthDataService.HasTruth(item.Id, item.Path)) .Select(item => new { Item = item, Action = PolicyResolver.Resolve(rules, item.Id, GetSeriesId(item), item.Genres) }) .Where(x => x.Action != PolicyAction.Ignore) .OrderByDescending(x => x.Action == PolicyAction.Prioritise) .ThenBy(_ => Random.Shared.Next()) .Take(effectiveLimit) .Select(x => new PendingExtractionItem { ItemId = x.Item.Id, Path = x.Item.Path, Name = x.Item.Name }); return Ok(pending); } /// /// Gets the series id for an episode, or for any /// other item type (so series rules only ever match episodes). /// /// The library item. /// The owning series id, or empty. internal static Guid GetSeriesId(BaseItem item) { return item is Episode episode ? episode.SeriesId : Guid.Empty; } /// /// Determines whether an item's backing media still exists on disk. /// /// A library item can outlive its file: deleting media from disk does not /// always purge the Jellyfin database entry, and such ghosts keep /// IsVirtualItem == false, so filtering on that flag alone is not /// enough. Without this check the pending endpoint hands stale paths/URLs to /// extraction workers, and — because a missing file can never gain truth /// data — those items stay pending forever. /// /// Only local (file-protocol) items are stat-checked; remote/streamed items /// are assumed present so we never stat something off-box. /// /// The library item. /// true if the item has usable backing media; otherwise false. internal static bool MediaFileExists(BaseItem item) { if (string.IsNullOrEmpty(item.Path)) { return false; } if (!item.IsFileProtocol) { return true; } // Path may point at a file or, for folder-based media, a directory. return System.IO.File.Exists(item.Path) || Directory.Exists(item.Path); } }