229 lines
7.7 KiB
C#
229 lines
7.7 KiB
C#
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;
|
|
|
|
/// <summary>
|
|
/// 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.
|
|
/// </summary>
|
|
[ApiController]
|
|
[Route("Plugins/JRay/Coverage")]
|
|
[Authorize(Roles = "Administrator")]
|
|
public class CoverageController : ControllerBase
|
|
{
|
|
private readonly ILibraryManager _libraryManager;
|
|
private readonly ITruthDataService _truthDataService;
|
|
private readonly IMediaPolicyStore _policyStore;
|
|
|
|
/// <summary>
|
|
/// Initializes a new instance of the <see cref="CoverageController"/> class.
|
|
/// </summary>
|
|
/// <param name="libraryManager">The Jellyfin library manager.</param>
|
|
/// <param name="truthDataService">The truth data service.</param>
|
|
/// <param name="policyStore">The media policy store.</param>
|
|
public CoverageController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
|
|
{
|
|
_libraryManager = libraryManager;
|
|
_truthDataService = truthDataService;
|
|
_policyStore = policyStore;
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the coverage report: totals plus breakdowns by media type and by genre.
|
|
/// </summary>
|
|
/// <returns>The coverage report.</returns>
|
|
[HttpGet]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public ActionResult<CoverageReport> 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<string, CoverageCounts>(StringComparer.Ordinal);
|
|
var byGenre = new Dictionary<string, CoverageCounts>(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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the distinct genres present on movies/episodes, for the rule editor.
|
|
/// </summary>
|
|
/// <returns>Genre options, sorted by name.</returns>
|
|
[HttpGet("Genres")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public ActionResult<IEnumerable<PickerOption>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Gets the series in the library, for the rule editor's series picker.
|
|
/// </summary>
|
|
/// <returns>Series options (id + title), sorted by title.</returns>
|
|
[HttpGet("Series")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public ActionResult<IEnumerable<PickerOption>> 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);
|
|
}
|
|
|
|
/// <summary>
|
|
/// Searches movies/episodes by name, for the rule editor's item picker.
|
|
/// </summary>
|
|
/// <param name="search">A name fragment to match (case-insensitive). Required.</param>
|
|
/// <param name="limit">The maximum number of results (default 25, max 100).</param>
|
|
/// <returns>Matching item options (id + name), sorted by name.</returns>
|
|
[HttpGet("Items")]
|
|
[ProducesResponseType(StatusCodes.Status200OK)]
|
|
public ActionResult<IEnumerable<PickerOption>> SearchItems([FromQuery] string? search, [FromQuery] int limit = 25)
|
|
{
|
|
if (string.IsNullOrWhiteSpace(search))
|
|
{
|
|
return Ok(Array.Empty<PickerOption>());
|
|
}
|
|
|
|
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<string, CoverageCounts> 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";
|
|
}
|
|
}
|