feat: prioritise and blacklist media and overview in setting page of progress in adding info
All checks were successful
🏗️ Build Plugin / build (push) Successful in 1m16s
Latest Release / latest-release (push) Successful in 27s
🧪 Test Plugin / test (push) Successful in 20s
🚀 Release Plugin / build-and-release (push) Successful in 24s

This commit is contained in:
Duncan Tourolle 2026-07-04 21:08:59 +02:00
parent bb02c0f9b9
commit f6762fcf29
17 changed files with 1211 additions and 16 deletions

View File

@ -39,6 +39,66 @@
</button> </button>
</div> </div>
</form> </form>
<h2 style="margin-top:2em;">Coverage</h2>
<p class="fieldDescription">
How much of your library has scene-actor truth data. Ignored items are
excluded from the "to&nbsp;do" total, so blacklisting a genre or series
won't drag your progress down.
</p>
<div id="JRayCoverageSummary" style="margin-bottom:1em;">Loading…</div>
<div id="JRayCoverageBar" style="height:1.25em;border-radius:0.25em;overflow:hidden;background:rgba(127,127,127,0.25);display:flex;margin-bottom:0.5em;"></div>
<div id="JRayCoverageLegend" class="fieldDescription" style="margin-bottom:1.5em;"></div>
<details style="margin-bottom:1.5em;">
<summary style="cursor:pointer;">By media type</summary>
<div id="JRayCoverageByType" style="margin-top:0.5em;"></div>
</details>
<details style="margin-bottom:1.5em;">
<summary style="cursor:pointer;">By genre</summary>
<div id="JRayCoverageByGenre" style="margin-top:0.5em;"></div>
</details>
<h2 style="margin-top:2em;">Prioritise / ignore rules</h2>
<p class="fieldDescription">
Shape the work-discovery API (<code>/Plugins/JRay/Tasks/Pending</code>).
<strong>Ignore</strong> hides matching items from extraction workers;
<strong>Prioritise</strong> pushes them to the front of the queue. A more
specific rule wins (item&nbsp;&gt;&nbsp;series&nbsp;&gt;&nbsp;genre). Setting a
rule for a target that already has one replaces it, so nothing can be both
prioritised and ignored.
</p>
<div style="display:flex;flex-wrap:wrap;gap:0.5em;align-items:flex-end;margin-bottom:1em;">
<div class="selectContainer" style="margin:0;">
<label class="selectLabel" for="JRayRuleScope">Scope</label>
<select id="JRayRuleScope" is="emby-select">
<option value="Genre">Genre</option>
<option value="Series">Series</option>
<option value="Item">Item</option>
</select>
</div>
<div id="JRayItemSearchContainer" class="inputContainer" style="margin:0;flex:1;min-width:14em;display:none;">
<label class="inputLabel inputLabelUnfocused" for="JRayItemSearch">Search title</label>
<input id="JRayItemSearch" type="text" is="emby-input" placeholder="Type a movie or episode name…" />
</div>
<div class="selectContainer" style="margin:0;flex:1;min-width:14em;">
<label class="selectLabel" for="JRayRuleValue">Target</label>
<select id="JRayRuleValue" is="emby-select"></select>
</div>
<div class="selectContainer" style="margin:0;">
<label class="selectLabel" for="JRayRuleAction">Action</label>
<select id="JRayRuleAction" is="emby-select">
<option value="Ignore">Ignore</option>
<option value="Prioritise">Prioritise</option>
</select>
</div>
<button id="JRayAddRule" is="emby-button" type="button" class="raised emby-button">
<span>Add rule</span>
</button>
</div>
<div id="JRayRulesList"></div>
</div> </div>
</div> </div>
<script type="text/javascript"> <script type="text/javascript">
@ -46,6 +106,230 @@
pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223' pluginUniqueId: '96a22d9d-23fd-49bb-8970-5e153817d223'
}; };
function jrayUrl(path) {
return ApiClient.getUrl('Plugins/JRay/' + path);
}
function jrayApi(path, method, body) {
var opts = {
url: jrayUrl(path),
type: method || 'GET'
};
if (body !== undefined) {
opts.data = JSON.stringify(body);
opts.contentType = 'application/json';
}
if (!method || method === 'GET') {
opts.dataType = 'json';
}
return ApiClient.ajax(opts);
}
function escapeHtml(s) {
return String(s == null ? '' : s)
.replace(/&/g, '&amp;').replace(/</g, '&lt;')
.replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}
// ---- Coverage ----
var JRayCoverColors = {
covered: '#4caf50',
prioritised: '#ff9800',
pending: '#9e9e9e',
ignored: 'rgba(127,127,127,0.35)'
};
function renderBar(el, counts) {
el.innerHTML = '';
var order = [
['covered', counts.covered],
['prioritised', counts.prioritised],
['pending', counts.pending - counts.prioritised],
['ignored', counts.ignored]
];
order.forEach(function (seg) {
if (counts.total <= 0 || seg[1] <= 0) { return; }
var d = document.createElement('div');
d.style.background = JRayCoverColors[seg[0]];
d.style.width = (100 * seg[1] / counts.total) + '%';
el.appendChild(d);
});
}
function pct(counts) {
var denom = counts.total - counts.ignored;
if (denom <= 0) { return '—'; }
return Math.round(100 * counts.covered / denom) + '%';
}
function renderBreakdown(el, rows) {
if (!rows || !rows.length) {
el.textContent = 'Nothing to show.';
return;
}
var html = '<table style="width:100%;border-collapse:collapse;">' +
'<thead><tr style="text-align:left;">' +
'<th>Name</th><th>Covered</th><th>Pending</th><th>Ignored</th><th>Total</th><th>%</th>' +
'</tr></thead><tbody>';
rows.forEach(function (r) {
var c = r.counts;
html += '<tr>' +
'<td>' + escapeHtml(r.label) + '</td>' +
'<td>' + c.covered + '</td>' +
'<td>' + c.pending + (c.prioritised ? ' (' + c.prioritised + ' ★)' : '') + '</td>' +
'<td>' + c.ignored + '</td>' +
'<td>' + c.total + '</td>' +
'<td>' + pct(c) + '</td>' +
'</tr>';
});
html += '</tbody></table>';
el.innerHTML = html;
}
function loadCoverage() {
document.querySelector('#JRayCoverageSummary').textContent = 'Loading…';
jrayApi('Coverage').then(function (report) {
var t = report.total;
document.querySelector('#JRayCoverageSummary').innerHTML =
'<strong>' + pct(t) + '</strong> covered — ' +
t.covered + ' of ' + (t.total - t.ignored) + ' items processed' +
(t.ignored ? ' (' + t.ignored + ' ignored)' : '') + '.';
renderBar(document.querySelector('#JRayCoverageBar'), t);
document.querySelector('#JRayCoverageLegend').innerHTML =
'<span style="color:' + JRayCoverColors.covered + '"></span> covered &nbsp; ' +
'<span style="color:' + JRayCoverColors.prioritised + '"></span> prioritised &nbsp; ' +
'<span style="color:' + JRayCoverColors.pending + '"></span> pending &nbsp; ' +
'<span></span> ignored';
renderBreakdown(document.querySelector('#JRayCoverageByType'), report.by_media_type);
renderBreakdown(document.querySelector('#JRayCoverageByGenre'), report.by_genre);
});
}
// ---- Rules ----
var JRayOptionCache = { Genre: null, Series: null };
function loadOptionsFor(scope) {
if (JRayOptionCache[scope]) {
return Promise.resolve(JRayOptionCache[scope]);
}
var path = scope === 'Series' ? 'Coverage/Series' : 'Coverage/Genres';
return jrayApi(path).then(function (opts) {
JRayOptionCache[scope] = opts;
return opts;
});
}
function fillSelect(select, opts, emptyText) {
if (!opts.length) {
select.innerHTML = '<option value="">' + emptyText + '</option>';
return;
}
select.innerHTML = opts.map(function (o) {
return '<option value="' + escapeHtml(o.value) + '">' + escapeHtml(o.label) + '</option>';
}).join('');
}
function populateValueSelect() {
var scope = document.querySelector('#JRayRuleScope').value;
var select = document.querySelector('#JRayRuleValue');
var isItem = scope === 'Item';
document.querySelector('#JRayItemSearchContainer').style.display = isItem ? '' : 'none';
if (isItem) {
// The target dropdown is filled from a live search instead of a full list.
select.innerHTML = '<option value="">Type a title to search…</option>';
return;
}
select.innerHTML = '<option value="">Loading…</option>';
loadOptionsFor(scope).then(function (opts) {
fillSelect(select, opts, '(none found)');
});
}
var JRayItemSearchTimer = null;
function onItemSearch() {
var term = document.querySelector('#JRayItemSearch').value.trim();
var select = document.querySelector('#JRayRuleValue');
clearTimeout(JRayItemSearchTimer);
if (!term) {
select.innerHTML = '<option value="">Type a title to search…</option>';
return;
}
select.innerHTML = '<option value="">Searching…</option>';
JRayItemSearchTimer = setTimeout(function () {
jrayApi('Coverage/Items?search=' + encodeURIComponent(term)).then(function (opts) {
fillSelect(select, opts, '(no matches)');
});
}, 300);
}
function labelForRule(rule) {
if (rule.label) { return rule.label; }
var cache = JRayOptionCache[rule.scope];
if (cache) {
var hit = cache.filter(function (o) { return o.value === rule.value; })[0];
if (hit) { return hit.label; }
}
return rule.value;
}
function loadRules() {
jrayApi('Policy/Rules').then(function (rules) {
var el = document.querySelector('#JRayRulesList');
if (!rules.length) {
el.innerHTML = '<p class="fieldDescription">No rules yet.</p>';
return;
}
var html = '<table style="width:100%;border-collapse:collapse;">' +
'<thead><tr style="text-align:left;"><th>Action</th><th>Scope</th><th>Target</th><th></th></tr></thead><tbody>';
rules.forEach(function (r) {
var badge = r.action === 'Prioritise'
? '<span style="color:' + JRayCoverColors.prioritised + '">★ Prioritise</span>'
: '<span>⦸ Ignore</span>';
html += '<tr>' +
'<td>' + badge + '</td>' +
'<td>' + escapeHtml(r.scope) + '</td>' +
'<td>' + escapeHtml(labelForRule(r)) + '</td>' +
'<td><button is="emby-button" type="button" class="emby-button jray-del" ' +
'data-scope="' + escapeHtml(r.scope) + '" data-value="' + escapeHtml(r.value) + '">Remove</button></td>' +
'</tr>';
});
html += '</tbody></table>';
el.innerHTML = html;
Array.prototype.forEach.call(el.querySelectorAll('.jray-del'), function (btn) {
btn.addEventListener('click', function () {
var q = 'Policy/Rules?scope=' + encodeURIComponent(btn.getAttribute('data-scope')) +
'&value=' + encodeURIComponent(btn.getAttribute('data-value'));
jrayApi(q, 'DELETE').then(function () {
loadRules();
loadCoverage();
});
});
});
});
}
function addRule() {
var scope = document.querySelector('#JRayRuleScope').value;
var valueSelect = document.querySelector('#JRayRuleValue');
var value = valueSelect.value;
if (!value) { return; }
var rule = {
scope: scope,
value: value,
action: document.querySelector('#JRayRuleAction').value,
label: valueSelect.options[valueSelect.selectedIndex].text
};
jrayApi('Policy/Rules', 'PUT', rule).then(function () {
loadRules();
loadCoverage();
});
}
document.querySelector('#JRayConfigPage') document.querySelector('#JRayConfigPage')
.addEventListener('pageshow', function() { .addEventListener('pageshow', function() {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();
@ -55,8 +339,16 @@
document.querySelector('#EnableOverlay').checked = config.EnableOverlay; document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
Dashboard.hideLoadingMsg(); Dashboard.hideLoadingMsg();
}); });
populateValueSelect();
loadRules();
loadCoverage();
}); });
document.querySelector('#JRayRuleScope').addEventListener('change', populateValueSelect);
document.querySelector('#JRayItemSearch').addEventListener('input', onItemSearch);
document.querySelector('#JRayAddRule').addEventListener('click', addRule);
document.querySelector('#JRayConfigForm') document.querySelector('#JRayConfigForm')
.addEventListener('submit', function(e) { .addEventListener('submit', function(e) {
Dashboard.showLoadingMsg(); Dashboard.showLoadingMsg();

View File

@ -0,0 +1,228 @@
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 (string.IsNullOrEmpty(item.Path))
{
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";
}
}

View File

@ -0,0 +1,76 @@
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Manages the prioritise/ignore rules that shape the work-discovery API
/// (<c>GET /Plugins/JRay/Tasks/Pending</c>). Rules can target a genre, a
/// series, or a single item; setting a rule for a target that already has one
/// replaces it, so a target can never be both prioritised and ignored.
/// </summary>
[ApiController]
[Route("Plugins/JRay/Policy")]
[Authorize(Roles = "Administrator")]
public class PolicyController : ControllerBase
{
private readonly IMediaPolicyStore _policyStore;
/// <summary>
/// Initializes a new instance of the <see cref="PolicyController"/> class.
/// </summary>
/// <param name="policyStore">The media policy store.</param>
public PolicyController(IMediaPolicyStore policyStore)
{
_policyStore = policyStore;
}
/// <summary>
/// Gets all configured prioritise/ignore rules.
/// </summary>
/// <returns>The current rules.</returns>
[HttpGet("Rules")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IReadOnlyList<MediaPolicyRule>> GetRules()
{
return Ok(_policyStore.GetRules());
}
/// <summary>
/// Adds or replaces a rule. If a rule already exists for the same scope
/// and value, its action is updated.
/// </summary>
/// <param name="rule">The rule to set.</param>
/// <returns>204 on success, or 400 if the rule has no value.</returns>
[HttpPut("Rules")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
[ProducesResponseType(StatusCodes.Status400BadRequest)]
public IActionResult SetRule([FromBody] MediaPolicyRule rule)
{
if (rule is null || string.IsNullOrWhiteSpace(rule.Value))
{
return BadRequest("A rule must have a non-empty value.");
}
_policyStore.SetRule(rule);
return NoContent();
}
/// <summary>
/// Removes the rule matching the given scope and value.
/// </summary>
/// <param name="scope">The scope of the rule to remove.</param>
/// <param name="value">The value of the rule to remove.</param>
/// <returns>204 whether or not a matching rule existed.</returns>
[HttpDelete("Rules")]
[ProducesResponseType(StatusCodes.Status204NoContent)]
public IActionResult RemoveRule([FromQuery] PolicyScope scope, [FromQuery] string value)
{
_policyStore.RemoveRule(scope, value ?? string.Empty);
return NoContent();
}
}

View File

@ -3,8 +3,10 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using Jellyfin.Data.Enums; using Jellyfin.Data.Enums;
using Jellyfin.Plugin.JRay.Models; using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces; using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization; using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Http;
@ -26,16 +28,19 @@ public class TasksController : ControllerBase
private readonly ILibraryManager _libraryManager; private readonly ILibraryManager _libraryManager;
private readonly ITruthDataService _truthDataService; private readonly ITruthDataService _truthDataService;
private readonly IMediaPolicyStore _policyStore;
/// <summary> /// <summary>
/// Initializes a new instance of the <see cref="TasksController"/> class. /// Initializes a new instance of the <see cref="TasksController"/> class.
/// </summary> /// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param> /// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="truthDataService">The truth data service.</param> /// <param name="truthDataService">The truth data service.</param>
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService) /// <param name="policyStore">The prioritise/ignore policy store.</param>
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
_truthDataService = truthDataService; _truthDataService = truthDataService;
_policyStore = policyStore;
} }
/// <summary> /// <summary>
@ -48,6 +53,7 @@ public class TasksController : ControllerBase
public ActionResult<IEnumerable<PendingExtractionItem>> GetPending([FromQuery] int limit = DefaultLimit) public ActionResult<IEnumerable<PendingExtractionItem>> GetPending([FromQuery] int limit = DefaultLimit)
{ {
var effectiveLimit = Math.Clamp(limit, 1, MaxLimit); var effectiveLimit = Math.Clamp(limit, 1, MaxLimit);
var rules = _policyStore.GetRules();
var items = _libraryManager.GetItemList(new InternalItemsQuery var items = _libraryManager.GetItemList(new InternalItemsQuery
{ {
@ -56,17 +62,38 @@ public class TasksController : ControllerBase
Recursive = true, 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 var pending = items
.Where(item => !string.IsNullOrEmpty(item.Path) && !_truthDataService.HasTruth(item.Id, item.Path)) .Where(item => !string.IsNullOrEmpty(item.Path) && !_truthDataService.HasTruth(item.Id, item.Path))
.OrderBy(_ => Random.Shared.Next()) .Select(item => new
.Take(effectiveLimit)
.Select(item => new PendingExtractionItem
{ {
ItemId = item.Id, Item = item,
Path = item.Path, Action = PolicyResolver.Resolve(rules, item.Id, GetSeriesId(item), item.Genres)
Name = item.Name })
.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); return Ok(pending);
} }
/// <summary>
/// Gets the series id for an episode, or <see cref="Guid.Empty"/> for any
/// other item type (so series rules only ever match episodes).
/// </summary>
/// <param name="item">The library item.</param>
/// <returns>The owning series id, or empty.</returns>
internal static Guid GetSeriesId(BaseItem item)
{
return item is Episode episode ? episode.SeriesId : Guid.Empty;
}
} }

View File

@ -0,0 +1,22 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// One labelled row of a coverage breakdown (e.g. a single genre, or a single
/// media type such as "Film" or "TV").
/// </summary>
public class CoverageBreakdownRow
{
/// <summary>
/// Gets or sets the label for this row (genre name or media type name).
/// </summary>
[JsonPropertyName("label")]
public string Label { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the coverage counts for this row.
/// </summary>
[JsonPropertyName("counts")]
public CoverageCounts Counts { get; set; } = new();
}

View File

@ -0,0 +1,41 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A count of library items bucketed by JRay coverage status. Used both for
/// the overall totals and for each per-genre / per-media-type breakdown row.
/// </summary>
public class CoverageCounts
{
/// <summary>
/// Gets or sets the total number of items in this bucket.
/// </summary>
[JsonPropertyName("total")]
public int Total { get; set; }
/// <summary>
/// Gets or sets the number of items that already have truth data.
/// </summary>
[JsonPropertyName("covered")]
public int Covered { get; set; }
/// <summary>
/// Gets or sets the number of items awaiting processing (no truth data,
/// not ignored). Includes prioritised-but-not-yet-covered items.
/// </summary>
[JsonPropertyName("pending")]
public int Pending { get; set; }
/// <summary>
/// Gets or sets the number of pending items that are prioritised.
/// </summary>
[JsonPropertyName("prioritised")]
public int Prioritised { get; set; }
/// <summary>
/// Gets or sets the number of items excluded from processing by an ignore rule.
/// </summary>
[JsonPropertyName("ignored")]
public int Ignored { get; set; }
}

View File

@ -0,0 +1,31 @@
using System.Collections.ObjectModel;
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A snapshot of how much of the library has truth data, with overall totals
/// and breakdowns by media type and by genre.
/// </summary>
public class CoverageReport
{
/// <summary>
/// Gets or sets the coverage counts across the whole library.
/// </summary>
[JsonPropertyName("total")]
public CoverageCounts Total { get; set; } = new();
/// <summary>
/// Gets the per-media-type breakdown (Film vs TV).
/// </summary>
[JsonPropertyName("by_media_type")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<CoverageBreakdownRow> ByMediaType { get; } = new();
/// <summary>
/// Gets the per-genre breakdown, one row per genre present in the library.
/// </summary>
[JsonPropertyName("by_genre")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<CoverageBreakdownRow> ByGenre { get; } = new();
}

View File

@ -0,0 +1,38 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A single prioritise/ignore rule. A rule is uniquely identified by its
/// <see cref="Scope"/> and <see cref="Value"/>; setting a new action for an
/// existing scope+value replaces the previous one.
/// </summary>
public class MediaPolicyRule
{
/// <summary>
/// Gets or sets the scope this rule targets (genre, series, or item).
/// </summary>
[JsonPropertyName("scope")]
public PolicyScope Scope { get; set; }
/// <summary>
/// Gets or sets the target value: a genre name for <see cref="PolicyScope.Genre"/>,
/// a series id for <see cref="PolicyScope.Series"/>, or an item id for
/// <see cref="PolicyScope.Item"/>.
/// </summary>
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the action to apply (prioritise or ignore).
/// </summary>
[JsonPropertyName("action")]
public PolicyAction Action { get; set; }
/// <summary>
/// Gets or sets an optional human-readable label for the target (e.g. the
/// series or item display name), shown in the rules list. Informational only.
/// </summary>
[JsonPropertyName("label")]
public string Label { get; set; } = string.Empty;
}

View File

@ -0,0 +1,23 @@
using System.Text.Json.Serialization;
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// A selectable option for the config page's rule editor (a genre or a series),
/// pairing the value stored in a rule with a human-readable label.
/// </summary>
public class PickerOption
{
/// <summary>
/// Gets or sets the value stored in a <see cref="MediaPolicyRule"/>
/// (a genre name, or a series id).
/// </summary>
[JsonPropertyName("value")]
public string Value { get; set; } = string.Empty;
/// <summary>
/// Gets or sets the label shown to the user (genre name, or series title).
/// </summary>
[JsonPropertyName("label")]
public string Label { get; set; } = string.Empty;
}

View File

@ -0,0 +1,15 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// The action a <see cref="MediaPolicyRule"/> applies to matching items.
/// A given scope+value can hold at most one action at a time, so an item
/// can never be both prioritised and ignored by the same target.
/// </summary>
public enum PolicyAction
{
/// <summary>Push matching items to the front of the work queue.</summary>
Prioritise,
/// <summary>Exclude matching items from the work queue entirely.</summary>
Ignore
}

View File

@ -0,0 +1,18 @@
namespace Jellyfin.Plugin.JRay.Models;
/// <summary>
/// The scope a <see cref="MediaPolicyRule"/> targets. More specific scopes
/// override broader ones when resolving an item's effective policy
/// (Item &gt; Series &gt; Genre).
/// </summary>
public enum PolicyScope
{
/// <summary>Matches every item that carries a given genre.</summary>
Genre,
/// <summary>Matches every episode of a given series (by series id).</summary>
Series,
/// <summary>Matches a single library item (by item id).</summary>
Item
}

View File

@ -16,5 +16,6 @@ public class ServiceRegistrator : IPluginServiceRegistrator
{ {
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>(); serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>(); serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
serviceCollection.AddSingleton<IMediaPolicyStore, MediaPolicyStore>();
} }
} }

View File

@ -0,0 +1,34 @@
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services.Interfaces;
/// <summary>
/// Stores the prioritise/ignore rules that shape the work-discovery API
/// (<c>GET /Plugins/JRay/Tasks/Pending</c>). Rules can target a genre, a
/// series, or a single item; more specific scopes override broader ones.
/// </summary>
public interface IMediaPolicyStore
{
/// <summary>
/// Gets all currently configured rules.
/// </summary>
/// <returns>The list of rules (a copy; safe to enumerate).</returns>
IReadOnlyList<MediaPolicyRule> GetRules();
/// <summary>
/// Adds or replaces the rule for a given scope+value. If a rule already
/// exists for the same scope and value, its action and label are updated,
/// so a target can never hold two conflicting actions.
/// </summary>
/// <param name="rule">The rule to set.</param>
void SetRule(MediaPolicyRule rule);
/// <summary>
/// Removes the rule matching the given scope and value, if present.
/// </summary>
/// <param name="scope">The scope of the rule to remove.</param>
/// <param name="value">The value of the rule to remove.</param>
/// <returns><c>true</c> if a rule was removed.</returns>
bool RemoveRule(PolicyScope scope, string value);
}

View File

@ -0,0 +1,139 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Common.Configuration;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Persists the prioritise/ignore rules to a single JSON file under the
/// plugin's configuration directory, and keeps an in-memory copy for fast
/// reads. All access is synchronised so the work-discovery API and the
/// config page can touch it concurrently.
/// </summary>
public sealed class MediaPolicyStore : IMediaPolicyStore
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
WriteIndented = true,
Converters = { new JsonStringEnumConverter() }
};
private readonly IApplicationPaths _applicationPaths;
private readonly ILogger<MediaPolicyStore> _logger;
private readonly object _gate = new();
private List<MediaPolicyRule>? _rules;
/// <summary>
/// Initializes a new instance of the <see cref="MediaPolicyStore"/> class.
/// </summary>
/// <param name="applicationPaths">The Jellyfin application paths.</param>
/// <param name="logger">The logger.</param>
public MediaPolicyStore(IApplicationPaths applicationPaths, ILogger<MediaPolicyStore> logger)
{
_applicationPaths = applicationPaths;
_logger = logger;
}
/// <inheritdoc />
public IReadOnlyList<MediaPolicyRule> GetRules()
{
lock (_gate)
{
return EnsureLoaded().ToList();
}
}
/// <inheritdoc />
public void SetRule(MediaPolicyRule rule)
{
ArgumentNullException.ThrowIfNull(rule);
lock (_gate)
{
var rules = EnsureLoaded();
rules.RemoveAll(r => r.Scope == rule.Scope && ValueEquals(r.Value, rule.Value));
rules.Add(rule);
Save(rules);
}
}
/// <inheritdoc />
public bool RemoveRule(PolicyScope scope, string value)
{
lock (_gate)
{
var rules = EnsureLoaded();
var removed = rules.RemoveAll(r => r.Scope == scope && ValueEquals(r.Value, value));
if (removed > 0)
{
Save(rules);
return true;
}
return false;
}
}
private static bool ValueEquals(string a, string b)
{
// Genre names are matched case-insensitively; ids happen to be
// case-insensitive too (GUID strings), so a single comparison suffices.
return string.Equals(a, b, StringComparison.OrdinalIgnoreCase);
}
private List<MediaPolicyRule> EnsureLoaded()
{
if (_rules is not null)
{
return _rules;
}
var path = GetPath();
if (!File.Exists(path))
{
_rules = new List<MediaPolicyRule>();
return _rules;
}
try
{
using var stream = File.OpenRead(path);
_rules = JsonSerializer.Deserialize<List<MediaPolicyRule>>(stream, JsonOptions)
?? new List<MediaPolicyRule>();
}
catch (Exception ex) when (ex is IOException or JsonException)
{
_logger.LogWarning(ex, "JRay: failed to read media policy file {Path}; starting empty", path);
_rules = new List<MediaPolicyRule>();
}
return _rules;
}
private void Save(List<MediaPolicyRule> rules)
{
var path = GetPath();
var directory = Path.GetDirectoryName(path) ?? throw new InvalidOperationException("Media policy path has no directory.");
Directory.CreateDirectory(directory);
var tempPath = path + ".tmp";
using (var stream = File.Create(tempPath))
{
JsonSerializer.Serialize(stream, rules, JsonOptions);
}
File.Move(tempPath, path, overwrite: true);
}
private string GetPath()
{
return Path.Combine(_applicationPaths.PluginConfigurationsPath, "JRay", "policy.json");
}
}

View File

@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using Jellyfin.Plugin.JRay.Models;
namespace Jellyfin.Plugin.JRay.Services;
/// <summary>
/// Resolves the effective prioritise/ignore action for an item from the
/// configured rules. More specific scopes win: an Item rule overrides a
/// Series rule, which overrides a Genre rule. Because a given scope+value can
/// hold only one action, the only conflicts possible are across scopes, and
/// specificity resolves those.
/// </summary>
public static class PolicyResolver
{
/// <summary>
/// Computes the effective action for an item, or <c>null</c> if no rule matches.
/// </summary>
/// <param name="rules">The configured rules.</param>
/// <param name="itemId">The item's id.</param>
/// <param name="seriesId">The item's series id, or <see cref="Guid.Empty"/> if it is not an episode.</param>
/// <param name="genres">The item's genres.</param>
/// <returns>The effective <see cref="PolicyAction"/>, or <c>null</c> when unruled.</returns>
public static PolicyAction? Resolve(
IReadOnlyList<MediaPolicyRule> rules,
Guid itemId,
Guid seriesId,
IReadOnlyList<string> genres)
{
ArgumentNullException.ThrowIfNull(rules);
PolicyAction? itemAction = null;
PolicyAction? seriesAction = null;
PolicyAction? genreAction = null;
var itemIdStr = itemId.ToString("D");
var seriesIdStr = seriesId == Guid.Empty ? null : seriesId.ToString("D");
foreach (var rule in rules)
{
switch (rule.Scope)
{
case PolicyScope.Item:
if (string.Equals(rule.Value, itemIdStr, StringComparison.OrdinalIgnoreCase))
{
itemAction = rule.Action;
}
break;
case PolicyScope.Series:
if (seriesIdStr is not null && string.Equals(rule.Value, seriesIdStr, StringComparison.OrdinalIgnoreCase))
{
seriesAction = rule.Action;
}
break;
case PolicyScope.Genre:
if (genres is not null && MatchesGenre(genres, rule.Value))
{
genreAction = rule.Action;
}
break;
}
}
return itemAction ?? seriesAction ?? genreAction;
}
private static bool MatchesGenre(IReadOnlyList<string> genres, string value)
{
for (var i = 0; i < genres.Count; i++)
{
if (string.Equals(genres[i], value, StringComparison.OrdinalIgnoreCase))
{
return true;
}
}
return false;
}
}

View File

@ -37,6 +37,11 @@ Then install "JRay" from the plugin catalog and restart Jellyfin.
- **Work discovery API** — a remote worker can poll for a random batch of library - **Work discovery API** — a remote worker can poll for a random batch of library
items that still need processing, so the backlog spreads naturally across items that still need processing, so the backlog spreads naturally across
workers without server-side task tracking. workers without server-side task tracking.
- **Prioritise / ignore rules** — steer that backlog by genre, series, or
individual item: ignore what you never want extracted (e.g. anime), or push a
series to the front of the queue.
- **Coverage overview** — see at a glance how much of your library has actor
data, broken down by genre and by media type (film vs TV).
- **Extensible "context at time t" envelope** — the per-timestamp response is - **Extensible "context at time t" envelope** — the per-timestamp response is
designed to grow (locations, trivia, …) without breaking existing clients. designed to grow (locations, trivia, …) without breaking existing clients.
- **In-memory caching** — loaded truth files are cached with a configurable TTL. - **In-memory caching** — loaded truth files are cached with a configurable TTL.
@ -107,7 +112,12 @@ the `X-Emby-Token: <token>` header or `Authorization: MediaBrowser Token="<token
| `GET /Items/{itemId}/Timeline` | user | Full truth file for an item, or `404` if none. | | `GET /Items/{itemId}/Timeline` | user | Full truth file for an item, or `404` if none. |
| `PUT /Items/{itemId}/Truth` | admin | Push managed truth data (schema v1). `204` on success, `400` on bad schema. | | `PUT /Items/{itemId}/Truth` | admin | Push managed truth data (schema v1). `204` on success, `400` on bad schema. |
| `DELETE /Items/{itemId}/Truth` | admin | Remove managed truth data (idempotent, `204`). Falls back to sidecar. | | `DELETE /Items/{itemId}/Truth` | admin | Remove managed truth data (idempotent, `204`). Falls back to sidecar. |
| `GET /Tasks/Pending?limit=10` | admin | Random sample of items still needing truth data (default 10, max 100). | | `GET /Tasks/Pending?limit=10` | admin | Random sample of items still needing truth data (default 10, max 100); honours prioritise/ignore rules. |
| `GET /Policy/Rules` | admin | List prioritise/ignore rules. |
| `PUT /Policy/Rules` | admin | Add or replace a rule. `204`, or `400` if `value` is empty. |
| `DELETE /Policy/Rules?scope=&value=` | admin | Remove a rule (idempotent, `204`). |
| `GET /Coverage` | admin | Library coverage totals, plus breakdowns by media type and genre. |
| `GET /Coverage/Genres` \| `/Series` \| `/Items?search=` | admin | Option lists for the config page's rule editor. |
| `GET /ClientScript` | anon | The pause-overlay script injected into the web client. | | `GET /ClientScript` | anon | The pause-overlay script injected into the web client. |
- **user** — any authenticated Jellyfin user token. - **user** — any authenticated Jellyfin user token.
@ -225,6 +235,21 @@ Configure JRay in Jellyfin Dashboard → Plugins → JRay:
client's `index.html`. Disabling it removes any previously injected script client's `index.html`. Disabling it removes any previously injected script
(default `on`). (default `on`).
The config page also shows a **coverage overview** (how much of your library has
actor data, by genre and media type) and a **prioritise / ignore rules** editor.
Rules steer the work-discovery API (`/Tasks/Pending`) only — they don't affect
the overlay or read endpoints:
- **Ignore** — matching items are never offered to extraction workers. Use it to
skip content you don't want processed (e.g. a whole genre like anime, a
specific series, or one movie). Ignored items are excluded from the coverage
"percent done" so they don't count against you.
- **Prioritise** — matching items jump to the front of the work queue.
A rule targets a **genre**, a **series**, or a single **item**; the most specific
matching rule wins (item > series > genre). Setting a rule for a target that
already has one replaces it, so nothing can be both prioritised and ignored.
## Building the Plugin ## Building the Plugin
### Prerequisites ### Prerequisites
@ -268,20 +293,28 @@ Jellyfin.Plugin.JRay/
├── Controllers/ ├── Controllers/
│ ├── ActorsController.cs # /Timeline and /jray?t= read endpoints │ ├── ActorsController.cs # /Timeline and /jray?t= read endpoints
│ ├── TruthController.cs # PUT/DELETE managed truth data │ ├── TruthController.cs # PUT/DELETE managed truth data
│ ├── TasksController.cs # /Tasks/Pending work discovery │ ├── TasksController.cs # /Tasks/Pending work discovery (policy-aware)
│ ├── PolicyController.cs # /Policy/Rules prioritise/ignore CRUD
│ ├── CoverageController.cs # /Coverage overview + genre/series/item pickers
│ └── WebController.cs # /ClientScript overlay script │ └── WebController.cs # /ClientScript overlay script
├── Models/ ├── Models/
│ ├── TruthFile.cs # Root truth-file schema (schema_version 1) │ ├── TruthFile.cs # Root truth-file schema (schema_version 1)
│ ├── TruthActor.cs # Per-actor entry with scene windows │ ├── TruthActor.cs # Per-actor entry with scene windows
│ ├── ActorAtTime.cs # Actor entry in the "context at t" envelope │ ├── ActorAtTime.cs # Actor entry in the "context at t" envelope
│ ├── JRayContext.cs # Extensible "context at time t" envelope │ ├── JRayContext.cs # Extensible "context at time t" envelope
│ └── PendingExtractionItem.cs # Item descriptor for the work-discovery API │ ├── PendingExtractionItem.cs # Item descriptor for the work-discovery API
│ ├── MediaPolicyRule.cs # A prioritise/ignore rule (+ PolicyScope/PolicyAction)
│ ├── CoverageReport.cs # Coverage overview (+ CoverageCounts / breakdown rows)
│ └── PickerOption.cs # value/label option for the config-page pickers
├── Services/ ├── Services/
│ ├── Interfaces/ │ ├── Interfaces/
│ │ ├── ITruthDataService.cs │ │ ├── ITruthDataService.cs
│ │ └── IManagedTruthStore.cs │ │ ├── IManagedTruthStore.cs
│ │ └── IMediaPolicyStore.cs
│ ├── TruthDataService.cs # Resolves + caches truth (managed > sidecar) │ ├── TruthDataService.cs # Resolves + caches truth (managed > sidecar)
│ ├── ManagedTruthStore.cs # Storage for pushed/managed truth data │ ├── ManagedTruthStore.cs # Storage for pushed/managed truth data
│ ├── MediaPolicyStore.cs # Persists prioritise/ignore rules (policy.json)
│ ├── PolicyResolver.cs # Resolves an item's effective rule (item>series>genre)
│ └── WebClientPatchService.cs # Injects/removes overlay script in index.html │ └── WebClientPatchService.cs # Injects/removes overlay script in index.html
├── Web/ ├── Web/
│ └── jray-overlay.js # Pause-overlay client script (embedded resource) │ └── jray-overlay.js # Pause-overlay client script (embedded resource)

103
SPEC.md
View File

@ -114,11 +114,104 @@ have no truth data yet (neither a managed upload nor a sidecar file):
] ]
``` ```
Requires an administrator API key. The sample is random and unordered, so Requires an administrator API key. The sample is random, so repeated polling
repeated polling naturally spreads work across the backlog without needing naturally spreads work across the backlog without needing server-side task
server-side task tracking; an empty array means there's nothing left to do tracking; an empty array means there's nothing left to do (or every remaining
(or every remaining item is a virtual/missing-path item that JRay can't item is a virtual/missing-path item that JRay can't process).
process).
**Prioritise/ignore rules apply here.** Items covered by an **ignore** rule are
never returned. Items covered by a **prioritise** rule are returned ahead of
un-prioritised items (still randomised within each tier). See
[Prioritise / ignore rules](#prioritise--ignore-rules) below. Rules only affect
this work-discovery endpoint — they never change the overlay or the read
endpoints, so an item you ignore for extraction still shows its overlay if truth
data happens to exist for it.
## Prioritise / ignore rules
Admins can steer the work-discovery queue with a small set of **rules**. Each
rule targets a **genre**, a **series**, or a single **item**, and either
**prioritises** (moves matching items to the front of `Tasks/Pending`) or
**ignores** them (hides them from `Tasks/Pending` entirely). This is how you
say "never extract anime", "process this series first", or "skip this one
movie".
Rule resolution for an item picks the **most specific** matching scope:
`Item` overrides `Series`, which overrides `Genre`. A rule is uniquely keyed by
its scope + value, and setting a rule for an existing scope+value **replaces**
it — so a single target can never be both prioritised and ignored. (An item can
still be pulled in two directions across scopes, e.g. a prioritised series in an
ignored genre; specificity resolves that — the series rule wins.)
Rules are persisted to `policy.json` under the plugin's configuration directory.
A rule object:
```json
{ "scope": "Genre", "value": "Anime", "action": "Ignore", "label": "Anime" }
```
- `scope`: `"Genre"`, `"Series"`, or `"Item"`.
- `value`: a genre name (for `Genre`), a series id GUID (for `Series`), or an
item id GUID (for `Item`). Genre matching is case-insensitive.
- `action`: `"Prioritise"` or `"Ignore"`.
- `label`: optional human-readable label shown in the config UI (informational).
All endpoints below require an **Administrator** API key.
### `GET /Plugins/JRay/Policy/Rules`
Returns all configured rules as a JSON array of rule objects.
### `PUT /Plugins/JRay/Policy/Rules`
Adds or replaces a rule (body is a single rule object). Replaces any existing
rule with the same `scope` + `value`. Returns `204`, or `400` if `value` is
empty.
### `DELETE /Plugins/JRay/Policy/Rules?scope={scope}&value={value}`
Removes the rule matching `scope` + `value`. Idempotent, always returns `204`.
## Coverage overview
### `GET /Plugins/JRay/Coverage`
Returns how much of the library has truth data, overall and broken down by
media type (Film vs TV) and by genre. Requires an **Administrator** API key.
```json
{
"total": { "total": 1200, "covered": 300, "pending": 850, "prioritised": 40, "ignored": 50 },
"by_media_type": [
{ "label": "Film", "counts": { "total": 400, "covered": 200, "pending": 190, "prioritised": 10, "ignored": 10 } },
{ "label": "TV", "counts": { "total": 800, "covered": 100, "pending": 660, "prioritised": 30, "ignored": 40 } }
],
"by_genre": [
{ "label": "Anime", "counts": { "total": 120, "covered": 0, "pending": 0, "prioritised": 0, "ignored": 120 } }
]
}
```
Each `counts` object buckets items as: `covered` (has truth data),
`pending` (needs processing and not ignored; `prioritised` is the subset of
`pending` under a prioritise rule), and `ignored` (excluded by an ignore rule).
`total` is the sum. A useful "percent done" is `covered / (total - ignored)`,
so ignoring a genre or series does **not** drag the percentage down — ignored
items are treated as intentionally out of scope.
An item counts toward every genre it carries, so genre rows can overlap and
their totals need not sum to the library total.
### Pickers for the config UI
Three helper endpoints populate the rule editor's dropdowns (all require an
**Administrator** API key, all return `[{ "value": ..., "label": ... }]`):
- `GET /Plugins/JRay/Coverage/Genres` — distinct genres present on movies/episodes.
- `GET /Plugins/JRay/Coverage/Series` — series in the library (`value` is the series id).
- `GET /Plugins/JRay/Coverage/Items?search={term}&limit={n}` — movies/episodes
whose name matches `term` (`value` is the item id; `limit` default 25, max
100). An empty/absent `search` returns `[]`.
## Client: pushing results from a remote extraction worker ## Client: pushing results from a remote extraction worker