Files
jRay/Jellyfin.Plugin.JRay/Configuration/configPage.html
T
dtourolleandClaude Opus 5 d9a38bb7fb
🏗️ Build Plugin / build (push) Successful in 59s
Latest Release / latest-release (push) Successful in 30s
🧪 Test Plugin / test (push) Successful in 22s
JR-021, JR-022, JR-023: File Transformation is a hard dependency
Deletes the on-disk index.html injection rather than leaving it switched off.
Plugin.cs had already stopped calling it, but an unreachable write path with a
live signature is the one a later refactor re-enables by accident, and it was
still the behaviour the README and the release changelog advertised.

Patching index.html on disk is destructive in ways a plugin cannot clean up
after: the patch outlives an uninstall, a web-client upgrade discards it
silently, and it races any other plugin touching the same file. It is also a
second code path, and the one nobody runs is the one that rots.

WebClientPatchService is now removal-only. The strip is factored out as
RemoveInjection so it is testable without a filesystem. Removal is the one
write JR-021 permits -- an earlier JRay did patch the file, and those users
must not be left with a stale injection pointing at endpoints that have since
changed. It keys on JRay's own marker, so it touches nothing another plugin
added.

JR-021 is a requirement to *not do* something, which no unit test can
demonstrate, so scripts/checks/no-index-injection.sh verifies it by absence.
The check was confirmed to fail on a reintroduced Apply() and on reintroduced
ReplaceLast injection -- a check that has only ever passed is not evidence.

Jellyfin has no plugin dependency mechanism, so nothing installs File
Transformation for the user and a log warning alone is one nobody reads.
GET /Plugins/JRay/Status/Dependencies reports whether the dependency is
satisfied, and the configuration page renders it with the repository URL and
what to do with it. Absent the plugin only the overlay is disabled; every
other feature works.

JR-022 and JR-023 stay In Progress rather than Done: neither has a test that
executes, and this repo has no test project yet.

TRACES: JR-021, JR-022, JR-023 | PR-004

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:58:12 +02:00

404 lines
21 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>JRay</title>
</head>
<body>
<div id="JRayConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox">
<div data-role="content">
<div class="content-primary">
<form id="JRayConfigForm">
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="TruthFileSuffix">Truth file suffix</label>
<input id="TruthFileSuffix" name="TruthFileSuffix" type="text" is="emby-input" />
<div class="fieldDescription">
Filename suffix used to find the scene-actor-extraction output for a media
file, e.g. "Movie.mkv" with suffix ".jray.json" looks for "Movie.jray.json"
in the same folder.
</div>
</div>
<div class="inputContainer">
<label class="inputLabel inputLabelUnfocused" for="CacheDurationMinutes">Cache duration (minutes)</label>
<input id="CacheDurationMinutes" name="CacheDurationMinutes" type="number" is="emby-input" min="0" />
<div class="fieldDescription">How long a loaded truth file is cached before being re-read from disk.</div>
</div>
<div class="checkboxContainer checkboxContainer-withDescription">
<label>
<input id="EnableOverlay" name="EnableOverlay" type="checkbox" is="emby-checkbox" />
<span>Enable pause overlay</span>
</label>
<div class="fieldDescription">
Shows the cast of the current scene when playback is paused. This
requires the
<a is="emby-linkbutton" class="button-link" href="https://github.com/IAmParadox27/jellyfin-plugin-file-transformation" target="_blank" rel="noopener">File Transformation</a>
plugin, which rewrites the web client's index.html as it is served.
JRay never modifies index.html on disk, so there is no fallback if
that plugin is absent — only the overlay is affected, and every
other JRay feature keeps working.
</div>
</div>
<div id="JRayDependencyStatus" class="fieldDescription" style="margin:0 0 1.5em;padding:0.75em 1em;border-radius:0.25em;display:none;"></div>
<div>
<button is="emby-button" type="submit" class="raised button-submit block emby-button">
<span>Save</span>
</button>
</div>
</form>
<h2 style="margin-top:2em;">Coverage</h2>
<p class="fieldDescription">
How much of your library has scene-actor truth data. Ignored items are
excluded from the "to&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>
<script type="text/javascript">
var JRayConfig = {
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;');
}
// ---- Dependency status ----
// Jellyfin cannot install a plugin's dependency, so the only thing
// that closes the gap is saying so where an admin can act on it.
function loadDependencyStatus() {
var el = document.querySelector('#JRayDependencyStatus');
return jrayApi('Status/Dependencies').then(function (status) {
el.style.display = '';
if (status.file_transformation_available) {
el.style.background = 'rgba(82,168,82,0.15)';
el.innerHTML = '<strong>File Transformation detected.</strong> '
+ 'The pause overlay is served by rewriting index.html as it is '
+ 'sent, leaving the file on disk untouched.';
return;
}
el.style.background = 'rgba(220,160,60,0.18)';
el.innerHTML = '<strong>File Transformation is not installed &mdash; the pause '
+ 'overlay is disabled.</strong> Every other JRay feature is unaffected. '
+ 'To enable it, add this repository in Dashboard &rarr; Plugins &rarr; '
+ 'Repositories, then install "File Transformation" and restart:<br />'
+ '<code>' + escapeHtml(status.file_transformation_manifest_url) + '</code>';
}).catch(function () {
el.style.display = 'none';
});
}
// ---- 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')
.addEventListener('pageshow', function() {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
document.querySelector('#TruthFileSuffix').value = config.TruthFileSuffix;
document.querySelector('#CacheDurationMinutes').value = config.CacheDurationMinutes;
document.querySelector('#EnableOverlay').checked = config.EnableOverlay;
Dashboard.hideLoadingMsg();
});
loadDependencyStatus();
populateValueSelect();
loadRules();
loadCoverage();
});
document.querySelector('#JRayRuleScope').addEventListener('change', populateValueSelect);
document.querySelector('#JRayItemSearch').addEventListener('input', onItemSearch);
document.querySelector('#JRayAddRule').addEventListener('click', addRule);
document.querySelector('#JRayConfigForm')
.addEventListener('submit', function(e) {
Dashboard.showLoadingMsg();
ApiClient.getPluginConfiguration(JRayConfig.pluginUniqueId).then(function (config) {
config.TruthFileSuffix = document.querySelector('#TruthFileSuffix').value;
config.CacheDurationMinutes = parseInt(document.querySelector('#CacheDurationMinutes').value, 10);
config.EnableOverlay = document.querySelector('#EnableOverlay').checked;
ApiClient.updatePluginConfiguration(JRayConfig.pluginUniqueId, config).then(function (result) {
Dashboard.processPluginConfigurationUpdateResult(result);
});
});
e.preventDefault();
return false;
});
</script>
</div>
</body>
</html>