Compare commits

..

No commits in common. "master" and "v0.0.0" have entirely different histories.

22 changed files with 389 additions and 1845 deletions

View File

@ -39,66 +39,6 @@
</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">
@ -106,230 +46,6 @@
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();
@ -339,16 +55,8 @@
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

@ -1,228 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Jellyfin.Data.Enums;
using Jellyfin.Plugin.JRay.Models;
using Jellyfin.Plugin.JRay.Services;
using Jellyfin.Plugin.JRay.Services.Interfaces;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Entities.TV;
using MediaBrowser.Controller.Library;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc;
namespace Jellyfin.Plugin.JRay.Controllers;
/// <summary>
/// Reports how much of the library has truth data (overall, by media type, and
/// by genre), and supplies the genre/series option lists the config page's
/// rule editor needs.
/// </summary>
[ApiController]
[Route("Plugins/JRay/Coverage")]
[Authorize(Roles = "Administrator")]
public class CoverageController : ControllerBase
{
private readonly ILibraryManager _libraryManager;
private readonly ITruthDataService _truthDataService;
private readonly IMediaPolicyStore _policyStore;
/// <summary>
/// Initializes a new instance of the <see cref="CoverageController"/> class.
/// </summary>
/// <param name="libraryManager">The Jellyfin library manager.</param>
/// <param name="truthDataService">The truth data service.</param>
/// <param name="policyStore">The media policy store.</param>
public CoverageController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
{
_libraryManager = libraryManager;
_truthDataService = truthDataService;
_policyStore = policyStore;
}
/// <summary>
/// Gets the coverage report: totals plus breakdowns by media type and by genre.
/// </summary>
/// <returns>The coverage report.</returns>
[HttpGet]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<CoverageReport> GetCoverage()
{
var rules = _policyStore.GetRules();
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
IsVirtualItem = false,
Recursive = true,
});
var report = new CoverageReport();
var byMediaType = new Dictionary<string, CoverageCounts>(StringComparer.Ordinal);
var byGenre = new Dictionary<string, CoverageCounts>(StringComparer.OrdinalIgnoreCase);
foreach (var item in items)
{
if (!TasksController.MediaFileExists(item))
{
continue;
}
var covered = _truthDataService.HasTruth(item.Id, item.Path);
var action = PolicyResolver.Resolve(rules, item.Id, TasksController.GetSeriesId(item), item.Genres);
Accumulate(report.Total, covered, action);
Accumulate(GetBucket(byMediaType, MediaTypeLabel(item)), covered, action);
foreach (var genre in item.Genres)
{
if (!string.IsNullOrWhiteSpace(genre))
{
Accumulate(GetBucket(byGenre, genre), covered, action);
}
}
}
foreach (var kvp in byMediaType.OrderBy(k => k.Key, StringComparer.Ordinal))
{
report.ByMediaType.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value });
}
foreach (var kvp in byGenre.OrderBy(k => k.Key, StringComparer.OrdinalIgnoreCase))
{
report.ByGenre.Add(new CoverageBreakdownRow { Label = kvp.Key, Counts = kvp.Value });
}
return Ok(report);
}
/// <summary>
/// Gets the distinct genres present on movies/episodes, for the rule editor.
/// </summary>
/// <returns>Genre options, sorted by name.</returns>
[HttpGet("Genres")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PickerOption>> GetGenres()
{
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
IsVirtualItem = false,
Recursive = true,
});
var genres = items
.SelectMany(item => item.Genres)
.Where(g => !string.IsNullOrWhiteSpace(g))
.Distinct(StringComparer.OrdinalIgnoreCase)
.OrderBy(g => g, StringComparer.OrdinalIgnoreCase)
.Select(g => new PickerOption { Value = g, Label = g });
return Ok(genres);
}
/// <summary>
/// Gets the series in the library, for the rule editor's series picker.
/// </summary>
/// <returns>Series options (id + title), sorted by title.</returns>
[HttpGet("Series")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PickerOption>> GetSeries()
{
var series = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Series },
IsVirtualItem = false,
Recursive = true,
});
var options = series
.OrderBy(s => s.Name, StringComparer.OrdinalIgnoreCase)
.Select(s => new PickerOption { Value = s.Id.ToString("D"), Label = s.Name });
return Ok(options);
}
/// <summary>
/// Searches movies/episodes by name, for the rule editor's item picker.
/// </summary>
/// <param name="search">A name fragment to match (case-insensitive). Required.</param>
/// <param name="limit">The maximum number of results (default 25, max 100).</param>
/// <returns>Matching item options (id + name), sorted by name.</returns>
[HttpGet("Items")]
[ProducesResponseType(StatusCodes.Status200OK)]
public ActionResult<IEnumerable<PickerOption>> SearchItems([FromQuery] string? search, [FromQuery] int limit = 25)
{
if (string.IsNullOrWhiteSpace(search))
{
return Ok(Array.Empty<PickerOption>());
}
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Episode },
IsVirtualItem = false,
Recursive = true,
SearchTerm = search,
Limit = Math.Clamp(limit, 1, 100),
});
var options = items
.OrderBy(i => i.Name, StringComparer.OrdinalIgnoreCase)
.Select(i => new PickerOption { Value = i.Id.ToString("D"), Label = ItemLabel(i) });
return Ok(options);
}
private static string ItemLabel(BaseItem item)
{
// Give episodes a series-qualified label so identically-named episodes
// (e.g. "Pilot") are distinguishable in the picker.
if (item is Episode episode && !string.IsNullOrEmpty(episode.SeriesName))
{
return episode.SeriesName + " — " + item.Name;
}
return item.Name;
}
private static void Accumulate(CoverageCounts counts, bool covered, PolicyAction? action)
{
counts.Total++;
if (action == PolicyAction.Ignore)
{
counts.Ignored++;
return;
}
if (covered)
{
counts.Covered++;
return;
}
counts.Pending++;
if (action == PolicyAction.Prioritise)
{
counts.Prioritised++;
}
}
private static CoverageCounts GetBucket(Dictionary<string, CoverageCounts> buckets, string key)
{
if (!buckets.TryGetValue(key, out var counts))
{
counts = new CoverageCounts();
buckets[key] = counts;
}
return counts;
}
private static string MediaTypeLabel(BaseItem item)
{
return item.GetBaseItemKind() == BaseItemKind.Episode ? "TV" : "Film";
}
}

View File

@ -1,76 +0,0 @@
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

@ -1,13 +1,10 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO;
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;
@ -29,19 +26,16 @@ 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>
/// <param name="policyStore">The prioritise/ignore policy store.</param> public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService)
public TasksController(ILibraryManager libraryManager, ITruthDataService truthDataService, IMediaPolicyStore policyStore)
{ {
_libraryManager = libraryManager; _libraryManager = libraryManager;
_truthDataService = truthDataService; _truthDataService = truthDataService;
_policyStore = policyStore;
} }
/// <summary> /// <summary>
@ -54,7 +48,6 @@ 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
{ {
@ -63,69 +56,17 @@ 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 => MediaFileExists(item) && !_truthDataService.HasTruth(item.Id, item.Path)) .Where(item => !string.IsNullOrEmpty(item.Path) && !_truthDataService.HasTruth(item.Id, item.Path))
.Select(item => new .OrderBy(_ => Random.Shared.Next())
{
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) .Take(effectiveLimit)
.Select(x => new PendingExtractionItem .Select(item => new PendingExtractionItem
{ {
ItemId = x.Item.Id, ItemId = item.Id,
Path = x.Item.Path, Path = item.Path,
Name = x.Item.Name Name = 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;
}
/// <summary>
/// Determines whether an item's backing media still exists on disk.
/// <para>
/// A library item can outlive its file: deleting media from disk does not
/// always purge the Jellyfin database entry, and such ghosts keep
/// <c>IsVirtualItem == false</c>, so filtering on that flag alone is not
/// enough. Without this check the pending endpoint hands stale paths/URLs to
/// extraction workers, and — because a missing file can never gain truth
/// data — those items stay pending forever.
/// </para>
/// Only local (file-protocol) items are stat-checked; remote/streamed items
/// are assumed present so we never stat something off-box.
/// </summary>
/// <param name="item">The library item.</param>
/// <returns><c>true</c> if the item has usable backing media; otherwise <c>false</c>.</returns>
internal static bool MediaFileExists(BaseItem item)
{
if (string.IsNullOrEmpty(item.Path))
{
return false;
}
if (!item.IsFileProtocol)
{
return true;
}
// Path may point at a file or, for folder-based media, a directory.
return System.IO.File.Exists(item.Path) || Directory.Exists(item.Path);
}
} }

View File

@ -11,10 +11,10 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.5" > <PackageReference Include="Jellyfin.Controller" Version="10.9.11" >
<ExcludeAssets>runtime</ExcludeAssets> <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.5"> <PackageReference Include="Jellyfin.Model" Version="10.9.11">
<ExcludeAssets>runtime</ExcludeAssets> <ExcludeAssets>runtime</ExcludeAssets>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>

View File

@ -1,22 +0,0 @@
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

@ -1,41 +0,0 @@
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

@ -1,31 +0,0 @@
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

@ -1,38 +0,0 @@
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

@ -1,23 +0,0 @@
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

@ -1,15 +0,0 @@
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

@ -1,18 +0,0 @@
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

@ -37,6 +37,5 @@ public class TruthActor
/// Gets the list of [start_sec, end_sec] windows during which the actor is on screen. /// Gets the list of [start_sec, end_sec] windows during which the actor is on screen.
/// </summary> /// </summary>
[JsonPropertyName("scenes")] [JsonPropertyName("scenes")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<double[]> Scenes { get; } = new(); public Collection<double[]> Scenes { get; } = new();
} }

View File

@ -37,6 +37,5 @@ public class TruthFile
/// Gets the list of actors detected in the film, each with their on-screen scene windows. /// Gets the list of actors detected in the film, each with their on-screen scene windows.
/// </summary> /// </summary>
[JsonPropertyName("actors")] [JsonPropertyName("actors")]
[JsonObjectCreationHandling(JsonObjectCreationHandling.Populate)]
public Collection<TruthActor> Actors { get; } = new(); public Collection<TruthActor> Actors { get; } = new();
} }

View File

@ -16,6 +16,5 @@ 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

@ -1,34 +0,0 @@
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

@ -1,139 +0,0 @@
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

@ -1,84 +0,0 @@
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

@ -2,64 +2,14 @@
'use strict'; 'use strict';
var POLL_INTERVAL_MS = 1000; var POLL_INTERVAL_MS = 1000;
var OVERVIEW_MAX_LENGTH = 160;
var overlayEl = null; var overlayEl = null;
var detailEl = null;
var personCache = {};
function truncate(text, maxLength) { function getItemIdFromHash() {
if (text.length <= maxLength) { var match = window.location.hash.match(/[?&]id=([0-9a-fA-F-]+)/);
return text; return match ? match[1] : null;
}
return text.slice(0, maxLength).trim() + '…';
}
function getPerson(jellyfinId) {
if (personCache[jellyfinId]) {
return personCache[jellyfinId];
}
var promise = window.ApiClient.getItem(window.ApiClient.getCurrentUserId(), jellyfinId)
.catch(function () {
return null;
});
personCache[jellyfinId] = promise;
return promise;
}
function getNowPlaying() {
if (!window.ApiClient) {
return Promise.reject(new Error('no ApiClient'));
}
var url = window.ApiClient.getUrl('Sessions', { DeviceId: window.ApiClient.deviceId() });
return window.ApiClient.ajax({ url: url, type: 'GET', dataType: 'json' }).then(function (sessions) {
var session = sessions && sessions[0];
if (!session || !session.NowPlayingItem || !session.PlayState) {
return null;
}
return {
itemId: session.NowPlayingItem.Id,
positionSeconds: (session.PlayState.PositionTicks || 0) / 10000000
};
});
}
function removeDetail() {
if (detailEl && detailEl.parentNode) {
detailEl.parentNode.removeChild(detailEl);
}
detailEl = null;
document.removeEventListener('keydown', onDetailKeydown, true);
} }
function removeOverlay() { function removeOverlay() {
removeDetail();
if (overlayEl && overlayEl.parentNode) { if (overlayEl && overlayEl.parentNode) {
overlayEl.parentNode.removeChild(overlayEl); overlayEl.parentNode.removeChild(overlayEl);
} }
@ -67,119 +17,6 @@
overlayEl = null; overlayEl = null;
} }
function onDetailKeydown(event) {
// Back button on TV remotes / keyboards maps to Escape / Backspace.
if (event.key === 'Escape' || event.key === 'Backspace' || event.keyCode === 27 || event.keyCode === 8) {
event.stopPropagation();
event.preventDefault();
removeDetail();
}
}
// A larger, closeable "pop-up" card shown over the video when an actor is
// clicked. It stays inside the player so playback position is never lost.
function showDetail(container, actor, person) {
removeDetail();
detailEl = document.createElement('div');
detailEl.className = 'jrayActorDetail';
detailEl.style.position = 'absolute';
detailEl.style.top = '0';
detailEl.style.left = '0';
detailEl.style.right = '0';
detailEl.style.bottom = '0';
detailEl.style.zIndex = '10000';
detailEl.style.display = 'flex';
detailEl.style.alignItems = 'center';
detailEl.style.justifyContent = 'center';
detailEl.style.background = 'rgba(0, 0, 0, 0.6)';
detailEl.style.pointerEvents = 'auto';
// Click on the dimmed backdrop closes the pop-up.
detailEl.addEventListener('click', function (event) {
if (event.target === detailEl) {
removeDetail();
}
});
var panel = document.createElement('div');
panel.style.position = 'relative';
panel.style.display = 'flex';
panel.style.gap = '24px';
panel.style.maxWidth = '720px';
panel.style.width = '80%';
panel.style.maxHeight = '80%';
panel.style.overflowY = 'auto';
panel.style.background = 'rgba(20, 20, 20, 0.96)';
panel.style.color = '#fff';
panel.style.padding = '24px';
panel.style.borderRadius = '10px';
panel.style.boxShadow = '0 8px 32px rgba(0, 0, 0, 0.6)';
if (person && person.ImageTags && person.ImageTags.Primary) {
var img = document.createElement('img');
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
type: 'Primary',
maxHeight: 400,
tag: person.ImageTags.Primary
});
img.style.height = '300px';
img.style.width = 'auto';
img.style.borderRadius = '8px';
img.style.objectFit = 'cover';
img.style.flexShrink = '0';
panel.appendChild(img);
}
var text = document.createElement('div');
text.style.flex = '1';
var name = document.createElement('div');
name.style.fontWeight = 'bold';
name.style.fontSize = '24px';
name.style.marginBottom = '12px';
name.textContent = actor.name;
text.appendChild(name);
if (person && person.Overview) {
var overview = document.createElement('div');
overview.style.fontSize = '15px';
overview.style.lineHeight = '1.5';
overview.style.opacity = '0.9';
overview.textContent = person.Overview;
text.appendChild(overview);
}
panel.appendChild(text);
var closeBtn = document.createElement('button');
closeBtn.type = 'button';
closeBtn.setAttribute('aria-label', 'Close');
closeBtn.textContent = '✕';
closeBtn.style.position = 'absolute';
closeBtn.style.top = '8px';
closeBtn.style.right = '8px';
closeBtn.style.width = '32px';
closeBtn.style.height = '32px';
closeBtn.style.border = 'none';
closeBtn.style.borderRadius = '50%';
closeBtn.style.background = 'rgba(255, 255, 255, 0.15)';
closeBtn.style.color = '#fff';
closeBtn.style.fontSize = '16px';
closeBtn.style.cursor = 'pointer';
closeBtn.addEventListener('click', function (event) {
event.stopPropagation();
removeDetail();
});
panel.appendChild(closeBtn);
detailEl.appendChild(panel);
container.appendChild(detailEl);
// Back button (Escape/Backspace) closes the pop-up first.
document.addEventListener('keydown', onDetailKeydown, true);
}
function showOverlay(video, actors) { function showOverlay(video, actors) {
removeOverlay(); removeOverlay();
@ -205,69 +42,13 @@
actors.forEach(function (actor) { actors.forEach(function (actor) {
var card = document.createElement('div'); var card = document.createElement('div');
card.className = 'jrayActorCard'; card.style.background = 'rgba(0, 0, 0, 0.7)';
card.style.background = 'rgba(0, 0, 0, 0.75)';
card.style.color = '#fff'; card.style.color = '#fff';
card.style.padding = '8px 12px'; card.style.padding = '6px 12px';
card.style.borderRadius = '6px'; card.style.borderRadius = '4px';
card.style.display = 'flex'; card.style.fontSize = '14px';
card.style.alignItems = 'center'; card.textContent = actor.name;
card.style.gap = '10px';
card.style.maxWidth = '360px';
var textBlock = document.createElement('div');
var name = document.createElement('div');
name.style.fontWeight = 'bold';
name.style.fontSize = '16px';
name.textContent = actor.name;
textBlock.appendChild(name);
card.appendChild(textBlock);
overlayEl.appendChild(card); overlayEl.appendChild(card);
if (!actor.jellyfin_id || !window.ApiClient) {
return;
}
card.style.pointerEvents = 'auto';
card.style.cursor = 'pointer';
getPerson(actor.jellyfin_id).then(function (person) {
if (!person || !overlayEl || !overlayEl.contains(card)) {
return;
}
// Clicking the card opens the in-player detail pop-up rather than
// navigating away — this keeps the current playback position.
card.addEventListener('click', function (event) {
event.stopPropagation();
showDetail(container, actor, person);
});
if (person.ImageTags && person.ImageTags.Primary) {
var img = document.createElement('img');
img.src = window.ApiClient.getImageUrl(actor.jellyfin_id, {
type: 'Primary',
maxHeight: 120,
tag: person.ImageTags.Primary
});
img.style.height = '120px';
img.style.width = 'auto';
img.style.borderRadius = '4px';
img.style.objectFit = 'cover';
card.insertBefore(img, textBlock);
}
if (person.Overview) {
var overview = document.createElement('div');
overview.style.fontSize = '12px';
overview.style.opacity = '0.85';
overview.style.marginTop = '4px';
overview.textContent = truncate(person.Overview, OVERVIEW_MAX_LENGTH);
textBlock.appendChild(overview);
}
});
}); });
container.appendChild(overlayEl); container.appendChild(overlayEl);
@ -291,14 +72,14 @@
}); });
} }
function onPause() { function onPause(event) {
getNowPlaying().then(function (info) { var video = event.target;
if (info) { var itemId = getItemIdFromHash();
fetchContext(info.itemId, info.positionSeconds); if (!itemId) {
return;
} }
}, function () {
// ApiClient unavailable or request error - fail silently, never break playback. fetchContext(itemId, video.currentTime);
});
} }
function onPlay() { function onPlay() {

751
README.md
View File

@ -1,19 +1,6 @@
# JRay # JRay
A Jellyfin plugin that brings an actor-overlay (think Amazon "X-Ray") feature to your media: pause a movie and JRay shows you which actors are on screen at that exact moment. JRay reads "truth" files produced offline by the scene-actor-extraction pipeline (face detection + recognition) and exposes an API to query which actors are visible on screen at a given timestamp in a movie, for building an actor-overlay (Jellyfin "X-Ray") style feature.
JRay reads "truth" files produced offline by the
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
pipeline (face detection + recognition) and exposes an API to query which actors
are visible at a given timestamp. A small overlay, injected into the Jellyfin web
client, displays the result when you pause playback.
## Status
**Alpha** — JRay works end to end (sidecar truth files, remote truth push, and the
pause overlay) but is early software and the truth-file schema may still change.
The overlay relies on patching the web client's `index.html`, which is inherently
a little fragile across Jellyfin versions (see [Important Notes](#important-notes)).
## Quick Install ## Quick Install
@ -23,372 +10,422 @@ Add this repository URL in Jellyfin (Dashboard → Plugins → Repositories):
https://gitea.tourolle.paris/dtourolle/jRay/raw/branch/master/manifest.json https://gitea.tourolle.paris/dtourolle/jRay/raw/branch/master/manifest.json
``` ```
Then install "JRay" from the plugin catalog and restart Jellyfin. Then install "JRay" from the plugin catalog.
## Features ---
- **Pause overlay** — pause a movie or episode in the web client and see the # So you want to make a Jellyfin plugin
actors currently on screen, without leaving the player.
- **Sidecar truth files** — drop a `Movie.jray.json` next to `Movie.mkv` and JRay
picks it up automatically (suffix configurable).
- **Remote truth push** — for servers that can't run the extraction pipeline
locally, a remote worker can `PUT` truth data over HTTP. Managed (pushed) data
takes precedence over sidecar files.
- **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.
- **Toggleable overlay** — disabling the overlay also cleanly removes the injected
script from the web client.
## How It Works Awesome! This guide is for you. Jellyfin plugins are written using the dotnet standard framework. What that means is you can write them in any language that implements the CLI or the DLI and can compile to net8.0. The examples on this page are in C# because that is what most of Jellyfin is written in, but F#, Visual Basic, and IronPython should all be compatible once compiled.
## 0. Things you need to get started
- [Dotnet SDK 9.0](https://dotnet.microsoft.com/en-us/download/dotnet)
- An editor of your choice. Some free choices are:
[Visual Studio Code](https://code.visualstudio.com)
[Visual Studio Community Edition](https://visualstudio.microsoft.com/downloads)
[Mono Develop](https://www.monodevelop.com)
## 0.5. Quickstarts
We have a number of quickstart options available to speed you along the way.
- [Download the Example Plugin Project](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/Jellyfin.Plugin.Template) from this repository, open it in your IDE and go to [step 3](https://github.com/jellyfin/jellyfin-plugin-template#3-customize-plugin-information)
- Install our dotnet template by [downloading the dotnet-template/content folder from this repo](https://github.com/jellyfin/jellyfin-plugin-template/tree/master/dotnet-template/content) or off of Nuget (Coming soon)
```
dotnet new -i /path/to/templatefolder
```
- Run this command then skip to step 4
```
dotnet new Jellyfin-plugin -name MyPlugin
```
If you'd rather start from scratch keep going on to step one. This assumes no specific editor or IDE and requires only the command line with dotnet in the path.
## 1. Initialize Your Project
Make a new dotnet standard project with the following command, it will make a directory for itself.
``` ```
scene-actor-extraction Jellyfin server web client dotnet new classlib -f net9.0 -n MyJellyfinPlugin
(offline pipeline) (browser)
┌────────────────────┐ ┌──────────────────┐ ┌────────────┐
│ face detection + │ truth │ JRay plugin │ jray?t= │ pause │
│ recognition │ ───────► │ - sidecar reader │ ◄─────── │ overlay │
│ result_sink_node │ file │ - managed store │ actors │ script │
└────────────────────┘ │ - REST API │ ───────► └────────────┘
│ PUT /Truth (remote) │ - web patcher │
└────────────────────────►└──────────────────┘
``` ```
1. The extraction pipeline analyses a film offline and emits a **truth file** Now add the Jellyfin shared libraries.
listing each detected actor and the time windows they're on screen.
2. JRay loads that truth file either from a **sidecar** next to the media
(`Movie.jray.json`) or from a **managed store** populated via the push API.
3. On startup JRay injects a small `<script>` into the web client's `index.html`.
When you pause, the script calls JRay for the current item and timestamp and
renders the on-screen actors as an overlay.
## Truth File Format ```
dotnet add package Jellyfin.Model
dotnet add package Jellyfin.Controller
```
For a media file `Movie.mkv`, JRay looks for a sibling `Movie.jray.json` (suffix You have an autogenerated Class1.cs file. You won't be needing this, so go ahead and delete it.
configurable). Schema (`schema_version: 1`, minimal verbosity):
```json Navigate to the csproj that was generated, and ensure that you modify the package references to exclude assets, so that unnecessary files aren't copied over.
{ Skipping this step will prevent your plugin from registering correctly.
"schema_version": 1, ```
"movie": "/path/to/Movie.mkv", <ItemGroup>
"sample_fps": 1, <PackageReference Include="Jellyfin.Controller" Version="10.11.3">
"anneal_sec": 2, <ExcludeAssets>runtime</ExcludeAssets>
"actors": [ </PackageReference>
<PackageReference Include="Jellyfin.Model" Version="10.11.3">
<ExcludeAssets>runtime</ExcludeAssets>
</PackageReference>
</ItemGroup>
```
Note: Ensure the package reference version matches the install version of jellyfin server, otherwise the plugin will show as NotSupported.
## 2. Set Up the Basics
There are a few mandatory classes you'll need for a plugin so we need to make them.
### PluginConfiguration
Create a folder named "Configuration", and a PluginConfiguration.cs file inside.
You can call it whatever you'd like really. This class is used to hold settings your plugin might need. We can leave it empty for now. This class should inherit from `MediaBrowser.Model.Plugins.BasePluginConfiguration`
It should look something like the following:
```c#
using MediaBrowser.Model.Plugins;
namespace MyJellyfinPlugin.Configuration;
class PluginConfiguration : BasePluginConfiguration
{ {
"name": "Tom Hanks",
"imdb_id": "nm0000158", }
"tmdb_id": "31", ```
"jellyfin_id": "abc123-guid",
"scenes": [[12.0, 45.0], [102.5, 150.0]] ### Plugin
This is the main class for your plugin and will reside in the root of your project. It will define your name, version and Id. It should inherit from `MediaBrowser.Common.Plugins.BasePlugin<PluginConfiguration>`
It should look something like the following:
```c#
using MediaBrowser.Common.Plugins;
using MyJellyfinPlugin.Configuration;
namespace MyJellyfinPlugin;
class Plugin : BasePlugin<PluginConfiguration>
{
}
```
Note: If you called your PluginConfiguration class something different, you need to put that between the <>
### Implement Required Properties
The Plugin class needs a few properties implemented before it can work correctly.
It needs an override on ID, an override on Name, and a constructor that follows a specific model. To get started you can use the following section.
```c#
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer){}
public override string Name => throw new System.NotImplementedException();
public override Guid Id => Guid.Parse("");
```
## 3. Customize Plugin Information
You need to populate some of your plugin's information. Go ahead a put in a string of the Name you've overridden name, and generate a GUID
- **Windows Users**: you can use the Powershell command `New-Guid`, `[guid]::NewGuid()` or the Visual Studio GUID generator
- **Linux and OS X Users**: you can use the Powershell Core command `New-Guid` or this command from your shell of choice:
```bash
od -x /dev/urandom | head -n1 | awk '{OFS="-"; srand($6); sub(/./,"4",$5); sub(/./,substr("89ab",1+rand()*4,1),$6); print $2$3,$4,$5,$6,$7$8$9}'
```
or
```bash
uuidgen
```
- Place that guid inside the `Guid.Parse("")` quotes to define your plugin's ID.
## 4. Adding Functionality
Congratulations, you now have everything you need for a perfectly functional functionless Jellyfin plugin! You can try it out right now if you'd like by compiling it, then placing the dll you generate in a subfolder (named after your plugin for example) within the plugins folder under your Jellyfin directory (Normally C:\Users\{YourUserName}\AppData\Local\jellyfin\plugins). If you want to try and hook it up to a debugger make sure you copy the generated PDB file alongside it.
Most people aren't satisfied with just having an entry in a menu for their plugin, most people want to have some functionality, so lets look at how to add it.
### 4a. Implement Interfaces
If the functionality you are trying to add is functionality related to something that Jellyfin has an interface for you're in luck. Jellyfin uses some automatic discovery and injection to allow any interfaces you implement in your plugin to be available in Jellyfin.
Here's some interfaces you could implement for common use cases:
- **IAuthenticationProvider** - Allows you to add an authentication provider that can authenticate a user based on a name and a password, but that doesn't expect to deal with local users.
- **IBaseItemComparer** - Allows you to add sorting rules for dealing with media that will show up in sort menus
- **IIntroProvider** - Allows you to play a piece of media before another piece of media (i.e. a trailer before a movie, or a network bumper before an episode of a show)
- **IItemResolver** - Allows you to define custom media types
- **ILibraryPostScanTask** - Allows you to define a task that fires after scanning a library
- **IMetadataSaver** - Allows you to define a metadata standard that Jellyfin can use to write metadata
- **IResolverIgnoreRule** - Allows you to define subpaths that are ignored by media resolvers for use with another function (i.e. you wanted to have a theme song for each tv series stored in a subfolder that could be accessed by your plugin for playback in a menu).
- **IScheduledTask** - Allows you to create a scheduled task that will appear in the scheduled task lists on the dashboard.
There are loads of other interfaces that can be used, but you'll need to poke around the API to get some info. If you're an expert on a particular interface, you should help [contribute some documentation](https://docs.jellyfin.org/general/contributing/index.html)!
### 4b. Use plugin aimed interfaces to add custom functionality
If your plugin doesn't fit perfectly neatly into a predefined interface, never fear, there are a set of interfaces and classes that allow your plugin to extend Jellyfin any which way you please. Here's a quick overview on how to use them
- **IPluginConfigurationPage** - Allows you to have a plugin config page on the dashboard. If you used one of the quickstart example projects, a premade page with some useful components to work with has been created for you! If not you can check out this guide here for how to whip one up.
**IPluginServiceRegistrator** - Will be located by Jellyfin at server startup and allows you to add services to the DI container to allow for injection in your plugin's classes later.
- **IHostedService** - Allows you to run code as a background task that will be started at program startup and will remain in memory. See [Microsoft's documentation](https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-8.0&tabs=visual-studio#ihostedservice-interface) for more information. You can make as many of these as you need; make Jellyfin aware of them with an `IPluginServiceRegistrator`. It is wildly useful for loading configs or persisting state. **Be aware that your main plugin class (IBasePlugin) cannot also be a IHostedService.**
- **ControllerBase** - Allows you to define custom REST-API endpoints. This is the default ASP.NET Web-API controller. You can use it exactly as you would in a normal Web-API project. Learn more about it [here](https://docs.microsoft.com/aspnet/core/web-api/?view=aspnetcore-5.0).
Likewise you might need to get data and services from the Jellyfin core, Jellyfin provides a number of interfaces you can add as parameters to your plugin constructor which are then made available in your project (you can see the 2 mandatory ones that are needed by the plugin system in the constructor as is).
- **IBlurayExaminer** - Allows you to examine blu-ray folders
- **IDtoService** - Allows you to create data transport objects, presumably to send to other plugins or to the core
- **ILibraryManager** - Allows you to directly access the media libraries without hopping through the API
- **ILocalizationManager** - Allows you tap into the main localization engine which governs translations, rating systems, units etc...
- **INetworkManager** - Allows you to get information about the server's networking status
- **IServerApplicationPaths** - Allows you to get the running server's paths
- **IServerConfigurationManager** - Allows you to write or read server configuration data into the application paths
- **ITaskManager** - Allows you to execute and manipulate scheduled tasks
- **IUserManager** - Allows you to retrieve user info and user library related info
- **IXmlSerializer** - Allows you to use the main xml serializer
- **IZipClient** - Allows you to use the core zip client for compressing and decompressing data
## 5. Create a Repository
- [See blog post](https://jellyfin.org/posts/plugin-updates/)
## 6. Set Up Debugging
Debugging can be set up by creating tasks which will be executed when running the plugin project. The specifics on setting up these tasks are not included as they may differ from IDE to IDE. The following list describes the general process:
- Compile the plugin in debug mode.
- Create the plugin directory if it doesn't exist.
- Copy the plugin into your server's plugin directory. The server will then execute it.
- Make sure to set the working directory of the program being debugged to the working directory of the Jellyfin Server.
- Start the server.
Some IDEs like Visual Studio Code may need the following compile flags to compile the plugin:
```shell
dotnet build Your-Plugin.sln /property:GenerateFullPaths=true /consoleloggerparameters:NoSummary
```
These flags generate the full paths for file names and **do not** generate a summary during the build process as this may lead to duplicate errors in the problem panel of your IDE.
### 6.a Set Up Debugging on Visual Studio
Visual Studio allows developers to connect to other processes and debug them, setting breakpoints and inspecting the variables of the program. We can set this up following this steps:
On this section we will explain how to set up our solution to enable debugging before the server starts.
1. Right-click on the solution, And click on Add -> Existing Project...
2. Locate Jellyfin executable in your installation folder and click on 'Open'. It is called `Jellyfin.exe`. Now The solution will have a new "Project" called Jellyfin. This is the executable, not the source code of Jellyfin.
3. Right-click on this new project and click on 'Set up as Startup Project'
4. Right-click on this new project and click on 'Properties'
5. Make sure that the 'Attach' parameter is set to 'No'
From now on, everytime you click on start from Visual Studio, it will start Jellyfin attached to the debugger!
The only thing left to do is to compile the project as it is specified a few lines above and you are done.
### 6.b Automate the Setup on Visual Studio Code
Visual Studio Code allows developers to automate the process of starting all necessary dependencies to start debugging the plugin. This guide assumes the reader is familiar with the [documentation on debugging in Visual Studio Code](https://code.visualstudio.com/docs/editor/debugging) and has read the documentation in this file. It is assumed that the Jellyfin Server has already been compiled once. However, should one desire to automatically compile the server before the start of the debugging session, this can be easily implemented, but is not further discussed here.
A full example, which aims to be portable may be found in this repo's `.vscode` folder.
This example expects you to clone `jellyfin`, `jellyfin-web` and `jellyfin-plugin-template` under the same parent directory, though you can customize this in `settings.json`
1. Create a `settings.json` file inside your `.vscode` folder, to specify common options specific to your local setup.
```jsonc
{
// jellyfinDir : The directory of the cloned jellyfin server project
// This needs to be built once before it can be used
"jellyfinDir" : "${workspaceFolder}/../jellyfin/Jellyfin.Server",
// jellyfinWebDir : The directory of the cloned jellyfin-web project
// This needs to be built once before it can be used
"jellyfinWebDir" : "${workspaceFolder}/../jellyfin-web",
// jellyfinDataDir : the root data directory for a running jellyfin instance
// This is where jellyfin stores its configs, plugins, metadata etc
// This is platform specific by default, but on Windows defaults to
// ${env:LOCALAPPDATA}/jellyfin
"jellyfinDataDir" : "${env:LOCALAPPDATA}/jellyfin",
// The name of the plugin
"pluginName" : "Jellyfin.Plugin.Template",
}
```
1. To automate the launch process, create a new `launch.json` file for C# projects inside the `.vscode` folder. The example below shows only the relevant parts of the file. Adjustments to your specific setup and operating system may be required.
```jsonc
{
// Paths and plugin names are configured in settings.json
"version": "0.2.0",
"configurations": [
{
"type": "coreclr",
"name": "Launch",
"request": "launch",
"preLaunchTask": "build-and-copy",
"program": "${config:jellyfinDir}/bin/Debug/net8.0/jellyfin.dll",
"args": [
//"--nowebclient"
"--webdir",
"${config:jellyfinWebDir}/dist/"
],
"cwd": "${config:jellyfinDir}",
} }
] ]
}
```
An actor is considered visible at timestamp `t` (seconds) if any of their
`scenes` windows satisfies `start <= t <= end`. JRay prefers `jellyfin_id` (a
Jellyfin Person GUID) when present, otherwise resolves `imdb_id`/`tmdb_id`
against the item's People `ProviderIds`.
See [SPEC.md](SPEC.md) for the full schema and field-by-field reference.
## API
All routes are served under `/Plugins/JRay`. Authentication uses Jellyfin's
standard scheme — pass a token (a user access token or an API key) as either
the `X-Emby-Token: <token>` header or `Authorization: MediaBrowser Token="<token>"`.
| Method & Route | Auth | Description |
| --- | --- | --- |
| `GET /Items/{itemId}/jray?t={seconds}` | user | "Context at time t" envelope (on-screen actors), or `404`. |
| `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); 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.
- **admin** — a token belonging to a user with the **Administrator** role
(create an API key under Dashboard → API Keys).
- **anon** — no authentication required.
## Client-Side Integration
JRay is designed so that *any* Jellyfin client (not just the bundled web overlay)
can build an actor-overlay feature. The integration is two calls: figure out
**what is playing and where**, then ask JRay **who is on screen**.
### 1. Query on-screen actors: `GET /Items/{itemId}/jray?t={seconds}`
Given a Jellyfin item id and a playback position in **seconds**, returns the
actors visible at that timestamp. This is the only call most clients need.
**Request**
```
GET /Plugins/JRay/Items/abc123-guid/jray?t=87.5
X-Emby-Token: <user-or-api-token>
```
**Response — `200 OK`**
```json
{
"actors": [
{
"name": "Tom Hanks",
"imdb_id": "nm0000158",
"tmdb_id": "31",
"jellyfin_id": "abc123-guid"
} }
```
The `request` type is specified as `launch`, as this `launch.json` file will start the Jellyfin Server process. The `preLaunchTask` defines a task that will run before the Jellyfin Server starts. More on this later. It is important to set the `program` path to the Jellyin Server program and set the current working directory (`cwd`) to the working directory of the Jellyfin Server.
The `args` option allows to specify arguments to be passed to the server, e.g. whether Jellyfin should start with the web-client or without it.
2. Create a `tasks.json` file inside your `.vscode` folder and specify a `build-and-copy` task that will run in `sequence` order. This tasks depends on multiple other tasks and all of those other tasks can be defined as simple `shell` tasks that run commands like the `cp` command to copy a file. The sequence to run those tasks in is given below. Please note that it might be necessary to adjust the examples for your specific setup and operating system.
The full file is shown here - Specific sections will be discussed in depth
```jsonc
{
// Paths and plugin name are configured in settings.json
"version": "2.0.0",
"tasks": [
{
// A chain task - build the plugin, then copy it to your
// jellyfin server's plugin directory
"label": "build-and-copy",
"dependsOrder": "sequence",
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
},
{
// Build the plugin
"label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"publish",
"${workspaceFolder}/${config:pluginName}.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
// Ensure the plugin directory exists before trying to use it
"label": "make-plugin-dir",
"type": "shell",
"command": "mkdir",
"args": [
"-Force",
"-Path",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
{
// Copy the plugin dll to the jellyfin plugin install path
// This command copies every .dll from the build directory to the plugin dir
// Usually, you probablly only need ${config:pluginName}.dll
// But some plugins may bundle extra requirements
"label": "copy-dll",
"type": "shell",
"command": "cp",
"args": [
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
] ]
}
```
- `actors` may be an **empty array** when no one is on screen at `t` — that's a },
`200`, not a `404`. ]
- `404 Not Found` means the item has **no truth data at all** (no managed upload }
and no sidecar file). Treat this as "JRay isn't available for this item" and
silently skip — don't surface an error to the viewer.
- The id fields are `""` when unresolved. Prefer `jellyfin_id` (a Jellyfin Person
GUID) to deep-link into the library or fetch a headshot; fall back to
`imdb_id` / `tmdb_id` for external links.
- The top-level object is an **extensible envelope**: future releases may add
sibling keys (e.g. `locations`, `trivia`) alongside `actors`. **Ignore unknown
keys** so your client keeps working across versions.
### 2. (Optional) Pre-fetch the whole timeline: `GET /Items/{itemId}/Timeline` ```
1. The "build-and-copy" task which triggers all of the other tasks
```jsonc
{
// A chain task - build the plugin, then copy it to your
// jellyfin server's plugin directory
"label": "build-and-copy",
"dependsOrder": "sequence",
"dependsOn": ["build", "make-plugin-dir", "copy-dll"]
},
```
2. A build task. This task builds the plugin without generating summary, but with full paths for file names enabled.
Returns the complete truth file (the [schema above](#truth-file-format)) — every ```jsonc
actor with all their scene windows. Use this if you'd rather fetch once and {
compute "who's on screen" client-side (e.g. to drive a scrubber-bar heatmap) // Build the plugin
instead of polling `jray?t=` on each pause. `404` if no truth data exists. "label": "build",
"command": "dotnet",
"type": "shell",
"args": [
"publish",
"${workspaceFolder}/${config:pluginName}.sln",
"/property:GenerateFullPaths=true",
"/consoleloggerparameters:NoSummary"
],
"group": "build",
"presentation": {
"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
```
### Reference implementation (web client) 3. A tasks which creates the necessary plugin directory and a sub-folder for the specific plugin. The plugin directory is located below the [data directory](https://jellyfin.org/docs/general/administration/configuration.html) of the Jellyfin Server. As an example, the following path can be used for the bookshelf plugin: `$HOME/.local/share/jellyfin/plugins/Bookshelf/`
```jsonc
{
// Ensure the plugin directory exists before trying to use it
"label": "make-plugin-dir",
"type": "shell",
"command": "mkdir",
"args": [
"-Force",
"-Path",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
```
The bundled overlay ([`Web/jray-overlay.js`](Jellyfin.Plugin.JRay/Web/jray-overlay.js)) 4. A tasks which copies the plugin dll which has been built in step 2.1. The file is copied into it's specific plugin directory within the server's plugin directory.
shows the full pattern using Jellyfin's `ApiClient`, and is a good template for a
custom client:
```js ```jsonc
// 1. Find what's playing and the current position (in seconds). {
var sessions = await ApiClient.ajax({ // Copy the plugin dll to the jellyfin plugin install path
url: ApiClient.getUrl('Sessions', { DeviceId: ApiClient.deviceId() }), // This command copies every .dll from the build directory to the plugin dir
type: 'GET', dataType: 'json' // Usually, you probablly only need ${config:pluginName}.dll
}); // But some plugins may bundle extra requirements
var s = sessions[0]; "label": "copy-dll",
var itemId = s.NowPlayingItem.Id; "type": "shell",
var t = (s.PlayState.PositionTicks || 0) / 10000000; // ticks → seconds "command": "cp",
"args": [
"./${config:pluginName}/bin/Debug/net8.0/publish/*",
"${config:jellyfinDataDir}/plugins/${config:pluginName}/"
]
},
```
// 2. Ask JRay who is on screen. ApiClient adds the auth token for you. ## Licensing
var ctx = await ApiClient.ajax({
url: ApiClient.getUrl('Plugins/JRay/Items/' + itemId + '/jray', { t: t }),
type: 'GET', dataType: 'json'
});
// 3. Render ctx.actors. A 404 (no truth data) rejects the promise — swallow it. Licensing is a complex topic. This repository features a GPLv3 license template that can be used to provide a good default license for your plugin. You may alter this if you like, but if you do a permissive license must be chosen.
```
Notes for client authors, learned from the reference overlay: Due to how plugins in Jellyfin work, when your plugin is compiled into a binary, it will link against the various Jellyfin binary NuGet packages. These packages are licensed under the GPLv3. Thus, due to the nature and restrictions of the GPL, the binary plugin you get will also be licensed under the GPLv3.
- **Position is in seconds.** Jellyfin reports `PositionTicks` (100 ns units); If you accept the default GPLv3 license from this template, all will be good. However if you choose a different license, please keep this fact in mind, as it might not always be obvious that an, e.g. MIT-licensed plugin would become GPLv3 when compiled.
divide by `10_000_000` before passing as `t`.
- **Fail silently.** A `404` or any network error must never interrupt playback —
just render nothing.
- **Enrich via the core API.** JRay returns ids, not images/bios. Use
`jellyfin_id` with the standard Jellyfin item/image endpoints (e.g.
`ApiClient.getItem(...)` / `getImageUrl(...)`) to show headshots and overviews,
and deep-link to `#/details?id=<jellyfin_id>`.
- **Refresh on player events.** The overlay recomputes on `pause` and clears on
`play` / `playing` / `seeking`. Poll `jray?t=` again after a seek rather than
reusing a stale result.
### Pushing truth data (remote extraction workers) Please note that this also means making "proprietary", source-unavailable, or otherwise "hidden" plugins for public consumption is not permitted. To build a Jellyfin plugin for distribution to others, it must be under the GPLv3 or a permissive open-source license that can be linked against the GPLv3.
For the full remote-worker workflow — authenticate, resolve the item id by
`Path`, and `PUT` the truth file — see
[SPEC.md](SPEC.md#client-pushing-results-from-a-remote-extraction-worker).
These endpoints require an **Administrator** token.
## Configuration
Configure JRay in Jellyfin Dashboard → Plugins → JRay:
- **Truth File Suffix** — filename suffix used to locate sidecar truth files next
to media (default `.jray.json`, e.g. `Movie.mkv``Movie.jray.json`).
- **Cache Duration (minutes)** — how long a loaded truth file is cached in memory
before being re-read from disk (default `60`).
- **Enable pause overlay** — whether JRay injects its overlay script into the web
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
- [.NET SDK 9.0](https://dotnet.microsoft.com/en-us/download/dotnet)
- Jellyfin 10.9.0 or later (built against `Jellyfin.Controller` 10.11.5)
### Build Steps
```bash
dotnet publish Jellyfin.Plugin.JRay/Jellyfin.Plugin.JRay.csproj -c Release
```
The compiled `Jellyfin.Plugin.JRay.dll` will be under
`Jellyfin.Plugin.JRay/bin/Release/net9.0/publish/`.
A reproducible build via the bundled Docker image is also available:
```bash
docker build -f Dockerfile.builder -t jray-builder .
```
## Manual Installation
1. Build the plugin (see above).
2. Copy the published output into a `JRay` subfolder of your Jellyfin plugins
directory (e.g. `~/.local/share/jellyfin/plugins/JRay/` on Linux, or
`%LOCALAPPDATA%\jellyfin\plugins\JRay\` on Windows).
3. Restart Jellyfin.
4. Configure JRay in Dashboard → Plugins → JRay.
## Technical Architecture
### Directory Structure
```
Jellyfin.Plugin.JRay/
├── Configuration/
│ ├── PluginConfiguration.cs # Suffix, cache TTL, overlay toggle
│ └── configPage.html # Dashboard config page (embedded resource)
├── Controllers/
│ ├── ActorsController.cs # /Timeline and /jray?t= read endpoints
│ ├── TruthController.cs # PUT/DELETE managed truth data
│ ├── 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
│ ├── 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
│ │ └── 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)
├── ServiceRegistrator.cs # DI registration
└── Plugin.cs # Plugin entry point; applies web patch on load
```
### Key Components
1. **Truth Data Service** (`TruthDataService`): resolves truth data for an item —
managed (pushed) data takes precedence over a sidecar file — and caches the
result in memory with the configured TTL.
2. **Managed Truth Store** (`ManagedTruthStore`): stores truth data pushed via the
API, independently of the media library filesystem.
3. **Controllers**: REST endpoints for reading (`ActorsController`), pushing
(`TruthController`), work discovery (`TasksController`), and serving the
overlay script (`WebController`).
4. **Web Client Patch Service** (`WebClientPatchService`): injects (or removes) the
`<script>` tag in the web client's `index.html`, marked with `<!-- jray-overlay -->`
so it's idempotent. Re-applied whenever configuration changes.
5. **Overlay Script** (`jray-overlay.js`): listens for the player's pause event,
calls `jray?t=`, and renders the on-screen actors.
## Important Notes
- **Web client overlay is a patch, not a hook.** Jellyfin has no plugin hook for
player UI, so JRay edits the web client's `index.html` directly. This generally
survives across restarts but may need re-applying after a Jellyfin web update;
toggling the overlay setting off and on re-runs the patch.
- **Truth data is produced offline.** JRay does not run face detection itself — it
consumes truth files from the
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
pipeline. No extraction = no overlay.
- **Schema version 1 only.** JRay accepts `schema_version: 1`. The schema may
change in future releases; bump-aware clients should send the version they
produced.
- **Remote path mapping.** Workers pushing truth match items by `Path`, so they
must see media at the same path Jellyfin does (translate paths first if mounts
differ).
## Contributing
Contributions welcome! This project is hosted on a self-hosted
[Gitea](https://gitea.tourolle.paris/dtourolle/jRay) instance.
**You don't need a separate account** — you can sign in with your existing GitHub
account. On the [sign-in page](https://gitea.tourolle.paris/user/login), choose
**"Sign in with GitHub"** to register and log in via GitHub OAuth. Once signed in,
you can:
- **Raise issues** — report bugs or request features on the
[issue tracker](https://gitea.tourolle.paris/dtourolle/jRay/issues).
- **Contribute code** — fork the repository, push a branch, and open a pull request.
## License
JRay is licensed under the **GNU General Public License v3.0**. See the
[LICENSE](LICENSE) file for details.
Because Jellyfin plugins link against the GPLv3-licensed Jellyfin NuGet packages,
the compiled plugin is necessarily GPLv3 as well.
## Acknowledgments
This plugin was developed partly using
[Claude Code](https://docs.anthropic.com/en/docs/claude-code) by Anthropic.
Built on the [Jellyfin plugin template](https://github.com/jellyfin/jellyfin-plugin-template)
and powered by the
[scene-actor-extraction](https://github.com/dtourolle/scene-actor-extraction)
pipeline.
## References
- [Jellyfin Plugin Documentation](https://jellyfin.org/docs/general/server/plugins/)
- [scene-actor-extraction pipeline](https://github.com/dtourolle/scene-actor-extraction)
- [JRay truth file specification](SPEC.md)

113
SPEC.md
View File

@ -53,21 +53,15 @@ For a given timestamp `t` (seconds), an actor is visible if any of their
## API ## API
All read endpoints below require an authenticated Jellyfin user token (passed as
`X-Emby-Token` or `Authorization: MediaBrowser Token="..."`); the admin endpoints
additionally require the **Administrator** role. Only `GET /Plugins/JRay/ClientScript`
is anonymous.
### `GET /Plugins/JRay/Items/{itemId}/Timeline` ### `GET /Plugins/JRay/Items/{itemId}/Timeline`
Returns the full truth file (schema above) for an item, or `404` if no truth Returns the full truth file (schema above) for an item, or `404` if no truth
data exists (neither a managed upload nor a sidecar file). Requires an data exists (neither a managed upload nor a sidecar file).
authenticated user token.
### `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}` ### `GET /Plugins/JRay/Items/{itemId}/jray?t={seconds}`
Returns an extensible "context at time t" envelope, or `404` if no truth Returns an extensible "context at time t" envelope, or `404` if no truth
data exists for the item. Requires an authenticated user token: data exists for the item:
```json ```json
{ {
@ -114,104 +108,11 @@ have no truth data yet (neither a managed upload nor a sidecar file):
] ]
``` ```
Requires an administrator API key. The sample is random, so repeated polling Requires an administrator API key. The sample is random and unordered, so
naturally spreads work across the backlog without needing server-side task repeated polling naturally spreads work across the backlog without needing
tracking; an empty array means there's nothing left to do (or every remaining server-side task tracking; an empty array means there's nothing left to do
item is a virtual/missing-path item that JRay can't process). (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 ## Client: pushing results from a remote extraction worker

View File

@ -7,85 +7,13 @@
"owner": "dtourolle", "owner": "dtourolle",
"category": "General", "category": "General",
"versions": [ "versions": [
{
"version": "0.0.4",
"changelog": "Release 0.0.4",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.4/jray_0.0.4.0.zip",
"checksum": "cb9613660b3fe3b730f46c9c7f257333",
"timestamp": "2026-07-04T19:47:05Z"
},
{ {
"version": "0.0.0.0", "version": "0.0.0.0",
"changelog": "Latest Build", "changelog": "Latest Build",
"targetAbi": "10.9.0.0", "targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip", "sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "a76316397228c9d8b8e38fbccad50390", "checksum": "d6f05de428a51573f020c06651245e69",
"timestamp": "2026-07-04T19:44:35Z" "timestamp": "2026-06-12T16:53:59Z"
},
{
"version": "0.0.3",
"changelog": "Release 0.0.3",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.3/jray_0.0.3.0.zip",
"checksum": "da5ded2ee720f7a7f7e15ac092871a91",
"timestamp": "2026-07-04T19:11:39Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "0d1cc8954a1b0557eb5ec6c368bce486",
"timestamp": "2026-07-04T19:10:48Z"
},
{
"version": "0.0.2",
"changelog": "Release 0.0.2",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.2/jray_0.0.2.0.zip",
"checksum": "6969ca24f23e66eaffae0b52638fea14",
"timestamp": "2026-06-12T18:30:35Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "2f8beef54c03cec92a17431615cba604",
"timestamp": "2026-06-12T18:26:36Z"
},
{
"version": "0.0.1",
"changelog": "Release 0.0.1",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.1/jray_0.0.1.0.zip",
"checksum": "6c0e71d77ebac619920cf93a2c330b8f",
"timestamp": "2026-06-12T18:11:18Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "6ed76e7fea217cda914a6610c48d7d30",
"timestamp": "2026-06-12T18:10:35Z"
},
{
"version": "0.0.0",
"changelog": "Release 0.0.0",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/v0.0.0/jray_0.0.0.0.zip",
"checksum": "fd92458f741b8f7aa0b77da04cbc6a43",
"timestamp": "2026-06-12T17:05:10Z"
},
{
"version": "0.0.0.0",
"changelog": "Latest Build",
"targetAbi": "10.9.0.0",
"sourceUrl": "https://gitea.tourolle.paris/dtourolle/jRay/releases/download/latest/jray_0.0.0.0.zip",
"checksum": "3650af90fd163042af65cc702d618506",
"timestamp": "2026-06-12T17:04:26Z"
} }
] ]
} }