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";
}
}