feat: prioritise and blacklist media and overview in setting page of progress in adding info
This commit is contained in:
parent
bb02c0f9b9
commit
f6762fcf29
@ -39,6 +39,66 @@
|
||||
</button>
|
||||
</div>
|
||||
</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 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 > series > 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>
|
||||
<script type="text/javascript">
|
||||
@ -46,6 +106,230 @@
|
||||
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, '&').replace(/</g, '<')
|
||||
.replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ---- 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 ' +
|
||||
'<span style="color:' + JRayCoverColors.prioritised + '">■</span> prioritised ' +
|
||||
'<span style="color:' + JRayCoverColors.pending + '">■</span> pending ' +
|
||||
'<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')
|
||||
.addEventListener('pageshow', function() {
|
||||
Dashboard.showLoadingMsg();
|
||||
@ -55,8 +339,16 @@
|
||||
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
|
||||
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')
|
||||
.addEventListener('submit', function(e) {
|
||||
Dashboard.showLoadingMsg();
|
||||
|
||||
228
Jellyfin.Plugin.JRay/Controllers/CoverageController.cs
Normal file
228
Jellyfin.Plugin.JRay/Controllers/CoverageController.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
76
Jellyfin.Plugin.JRay/Controllers/PolicyController.cs
Normal file
76
Jellyfin.Plugin.JRay/Controllers/PolicyController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
@ -3,8 +3,10 @@ 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;
|
||||
@ -26,16 +28,19 @@ public class TasksController : ControllerBase
|
||||
|
||||
private readonly ILibraryManager _libraryManager;
|
||||
private readonly ITruthDataService _truthDataService;
|
||||
private readonly IMediaPolicyStore _policyStore;
|
||||
|
||||
/// <summary>
|
||||
/// Initializes a new instance of the <see cref="TasksController"/> class.
|
||||
/// </summary>
|
||||
/// <param name="libraryManager">The Jellyfin library manager.</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;
|
||||
_truthDataService = truthDataService;
|
||||
_policyStore = policyStore;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
@ -48,6 +53,7 @@ public class TasksController : ControllerBase
|
||||
public ActionResult<IEnumerable<PendingExtractionItem>> GetPending([FromQuery] int limit = DefaultLimit)
|
||||
{
|
||||
var effectiveLimit = Math.Clamp(limit, 1, MaxLimit);
|
||||
var rules = _policyStore.GetRules();
|
||||
|
||||
var items = _libraryManager.GetItemList(new InternalItemsQuery
|
||||
{
|
||||
@ -56,17 +62,38 @@ public class TasksController : ControllerBase
|
||||
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
|
||||
.Where(item => !string.IsNullOrEmpty(item.Path) && !_truthDataService.HasTruth(item.Id, item.Path))
|
||||
.OrderBy(_ => Random.Shared.Next())
|
||||
.Take(effectiveLimit)
|
||||
.Select(item => new PendingExtractionItem
|
||||
.Select(item => new
|
||||
{
|
||||
ItemId = item.Id,
|
||||
Path = item.Path,
|
||||
Name = item.Name
|
||||
Item = item,
|
||||
Action = PolicyResolver.Resolve(rules, item.Id, GetSeriesId(item), item.Genres)
|
||||
})
|
||||
.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);
|
||||
}
|
||||
|
||||
/// <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;
|
||||
}
|
||||
}
|
||||
|
||||
22
Jellyfin.Plugin.JRay/Models/CoverageBreakdownRow.cs
Normal file
22
Jellyfin.Plugin.JRay/Models/CoverageBreakdownRow.cs
Normal 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();
|
||||
}
|
||||
41
Jellyfin.Plugin.JRay/Models/CoverageCounts.cs
Normal file
41
Jellyfin.Plugin.JRay/Models/CoverageCounts.cs
Normal 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; }
|
||||
}
|
||||
31
Jellyfin.Plugin.JRay/Models/CoverageReport.cs
Normal file
31
Jellyfin.Plugin.JRay/Models/CoverageReport.cs
Normal 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();
|
||||
}
|
||||
38
Jellyfin.Plugin.JRay/Models/MediaPolicyRule.cs
Normal file
38
Jellyfin.Plugin.JRay/Models/MediaPolicyRule.cs
Normal 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;
|
||||
}
|
||||
23
Jellyfin.Plugin.JRay/Models/PickerOption.cs
Normal file
23
Jellyfin.Plugin.JRay/Models/PickerOption.cs
Normal 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;
|
||||
}
|
||||
15
Jellyfin.Plugin.JRay/Models/PolicyAction.cs
Normal file
15
Jellyfin.Plugin.JRay/Models/PolicyAction.cs
Normal 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
|
||||
}
|
||||
18
Jellyfin.Plugin.JRay/Models/PolicyScope.cs
Normal file
18
Jellyfin.Plugin.JRay/Models/PolicyScope.cs
Normal 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 > Series > 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
|
||||
}
|
||||
@ -16,5 +16,6 @@ public class ServiceRegistrator : IPluginServiceRegistrator
|
||||
{
|
||||
serviceCollection.AddSingleton<IManagedTruthStore, ManagedTruthStore>();
|
||||
serviceCollection.AddSingleton<ITruthDataService, TruthDataService>();
|
||||
serviceCollection.AddSingleton<IMediaPolicyStore, MediaPolicyStore>();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
139
Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs
Normal file
139
Jellyfin.Plugin.JRay/Services/MediaPolicyStore.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
84
Jellyfin.Plugin.JRay/Services/PolicyResolver.cs
Normal file
84
Jellyfin.Plugin.JRay/Services/PolicyResolver.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
41
README.md
41
README.md
@ -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
|
||||
items that still need processing, so the backlog spreads naturally across
|
||||
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
|
||||
designed to grow (locations, trivia, …) without breaking existing clients.
|
||||
- **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. |
|
||||
| `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. |
|
||||
| `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. |
|
||||
|
||||
- **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
|
||||
(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
|
||||
|
||||
### Prerequisites
|
||||
@ -268,20 +293,28 @@ Jellyfin.Plugin.JRay/
|
||||
├── Controllers/
|
||||
│ ├── ActorsController.cs # /Timeline and /jray?t= read endpoints
|
||||
│ ├── 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
|
||||
├── Models/
|
||||
│ ├── TruthFile.cs # Root truth-file schema (schema_version 1)
|
||||
│ ├── TruthActor.cs # Per-actor entry with scene windows
|
||||
│ ├── ActorAtTime.cs # Actor entry in the "context at 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/
|
||||
│ ├── Interfaces/
|
||||
│ │ ├── ITruthDataService.cs
|
||||
│ │ └── IManagedTruthStore.cs
|
||||
│ │ ├── IManagedTruthStore.cs
|
||||
│ │ └── IMediaPolicyStore.cs
|
||||
│ ├── TruthDataService.cs # Resolves + caches truth (managed > sidecar)
|
||||
│ ├── 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
|
||||
├── Web/
|
||||
│ └── jray-overlay.js # Pause-overlay client script (embedded resource)
|
||||
|
||||
103
SPEC.md
103
SPEC.md
@ -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
|
||||
repeated polling naturally spreads work across the backlog without needing
|
||||
server-side task tracking; an empty array means there's nothing left to do
|
||||
(or every remaining item is a virtual/missing-path item that JRay can't
|
||||
process).
|
||||
Requires an administrator API key. The sample is random, so repeated polling
|
||||
naturally spreads work across the backlog without needing server-side task
|
||||
tracking; an empty array means there's nothing left to do (or every remaining
|
||||
item is a virtual/missing-path item that JRay can't 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
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user