using System; using System.Collections.Generic; 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; /// /// Reports how much of the library has truth data (overall, by media type, and /// by genre), and supplies the genre/series option lists the config page's /// rule editor needs. /// [ApiController] [Route("Plugins/JRay/Coverage")] [Authorize(Roles = "Administrator")] public class CoverageController : ControllerBase { 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 media policy store. public CoverageController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore) { _libraryManager = libraryManager; _truthDataService = truthDataService; _policyStore = policyStore; } /// /// Gets the coverage report: totals plus breakdowns by media type and by genre. /// /// The coverage report. [HttpGet] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult GetCoverage() { var rules = _policyStore.GetRules(); var items = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode }, IsVirtualItem = false, Recursive = true, }); var report = new CoverageReport(); var byMediaType = new Dictionary(StringComparer.Ordinal); var byGenre = new Dictionary(StringComparer.OrdinalIgnoreCase); foreach (var item in items) { if (!TasksController.MediaFileExists(item)) { continue; } var covered = _truthDataService.HasTruth(item.Id, item.Path); var action = PolicyResolver.Resolve(rules, item.Id, TasksController.GetSeriesId(item), item.Genres); Accumulate(report.Total, covered, action); Accumulate(GetBucket(byMediaType, MediaTypeLabel(item)), covered, action); foreach (var genre in item.Genres) { if (!string.IsNullOrWhiteSpace(genre)) { Accumulate(GetBucket(byGenre, genre), covered, action); } } } foreach (var kvp in byMediaType.OrderBy(k => k.Key, StringComparer.Ordinal)) { report.ByMediaType.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value }); } foreach (var kvp in byGenre.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase)) { report.ByGenre.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value }); } return Ok(report); } /// /// Gets the distinct genres present on movies/episodes, for the rule editor. /// /// Genre options, sorted by name. [HttpGet("Genres")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetGenres() { var items = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode }, IsVirtualItem = false, Recursive = true, }); var genres = items .SelectMany(item => item.Genres) .Where(g => !string.IsNullOrWhiteSpace(g)) .Distinct(StringComparer.OrdinalIgnoreCase) .OrderBy(g => g, StringComparer.OrdinalIgnoreCase) .Select(g => new PickerOption { Value = g, Label = g }); return Ok(genres); } /// /// Gets the series in the library, for the rule editor's series picker. /// /// Series options (id + title), sorted by title. [HttpGet("Series")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> GetSeries() { var series = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Series }, IsVirtualItem = false, Recursive = true, }); var options = series .OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase) .Select(s => new PickerOption { Value = s.Id.ToString("D"), Label = s.Name }); return Ok(options); } /// /// Searches movies/episodes by name, for the rule editor's item picker. /// /// A name fragment to match (case-insensitive). Required. /// The maximum number of results (default 25, max 100). /// Matching item options (id + name), sorted by name. [HttpGet("Items")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult> SearchItems([FromQuery] string? search, [FromQuery] int limit = 25) { if (string.IsNullOrWhiteSpace(search)) { return Ok(Array.Empty()); } var items = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode }, IsVirtualItem = false, Recursive = true, SearchTerm = search, Limit = Math.Clamp(limit, 1, 100), }); var options = items .OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase) .Select(i => new PickerOption { Value = i.Id.ToString("D"), Label = ItemLabel(i) }); return Ok(options); } private static string ItemLabel(BaseItem item) { // Give episodes a series-qualified label so identically-named episodes // (e.g. "Pilot") are distinguishable in the picker. if (item is Episode episode && !string.IsNullOrEmpty(episode.SeriesName)) { return episode.SeriesName + " — " + item.Name; } return item.Name; } private static void Accumulate(CoverageCounts counts, bool covered, PolicyAction? action) { counts.Total++; if (action == PolicyAction.Ignore) { counts.Ignored++; return; } if (covered) { counts.Covered++; return; } counts.Pending++; if (action == PolicyAction.Prioritise) { counts.Prioritised++; } } private static CoverageCounts GetBucket(Dictionary buckets, string key) { if (!buckets.TryGetValue(key, out var counts)) { counts = new CoverageCounts(); buckets[key] = counts; } return counts; } private static string MediaTypeLabel(BaseItem item) { return item.GetBaseItemKind() == BaseItemKind.Episode ? "TV" : "Film"; } }