jRay had no requirement IDs, so nothing in this repo could be traced to and the CI gate had no denominator to read. The other two components had already moved to registers; this brings the plugin level with them. Adds docs/requirements.md with 46 permanent JR-nnn IDs, each carrying a parent requirement, priority, status and verification tier, plus a per-requirement verification plan. JR is flat rather than split by theme: the plugin is one deployable with one audience, and JRay-public-server already ships UR/DR, so a second repo using those prefixes would make UR-007 ambiguous across registers. Rewrites SPEC.md as requirements prose with Current:/Gap: on every one. It had drifted into a format-plus-API reference that documented schema_version 1 while owning a format whose v2 shape was specified only in the other two repos, said nothing about SR-002's scene-scoped semantics, and carried the manifest exchange as a "planned" aside while its configuration classes were already implemented. Plugin-side exchange obligations move here from the server's spec, where they were an ownership inversion. Adds JR-038..041 for PR-005, which had no software row in any repo -- it was held structurally by SR-004 and GR-005 both being prohibitions, and a goal preserved only by prohibitions is the kind that erodes unnoticed. jRay is the component that actually opens a socket. Tags 18 units with the requirements they satisfy. Tags name what the code satisfies, so FileTransformationRegistration is not tagged JR-021: that requirement is a prohibition and was still violated elsewhere when this was written. Vendors jray-project as a submodule for the system spec and shared gate. TRACES: JR-001, JR-004, JR-005, JR-007, JR-008, JR-009, JR-010, JR-011 TRACES: JR-012, JR-013, JR-014, JR-015, JR-016, JR-017, JR-018, JR-019 TRACES: JR-020, JR-024, JR-025, JR-036, JR-038 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
235 lines
7.9 KiB
C#
235 lines
7.9 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>
|
|
/// <remarks>
|
|
/// Percent done is <c>covered / (total - ignored)</c>: ignored items are
|
|
/// intentionally out of scope, so excluding a genre must not drag the figure
|
|
/// down as though it were outstanding work.
|
|
/// </remarks>
|
|
[ApiController]
|
|
[Route("Plugins/JRay/Coverage")]
|
|
[Authorize(Roles = "Administrator")]
|
|
// TRACES: JR-018, JR-019 | PR-003
|
|
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";
|
|
}
|
|
}
|