371 lines
18 KiB
HTML
371 lines
18 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">
|
|
Injects a small script into the web client that shows on-screen actors
|
|
when playback is paused. Disabling this removes the injected script.
|
|
</div>
|
|
</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 do" total, so blacklisting a genre or series
|
|
won't drag your progress down.
|
|
</p>
|
|
<div id="JRayCoverageSummary" style="margin-bottom:1em;">Loading…</div>
|
|
<div id="JRayCoverageBar" style="height:1.25em;border-radius:0.25em;overflow:hidden;background:rgba(127,127,127,0.25);display:flex;margin-bottom:0.5em;"></div>
|
|
<div id="JRayCoverageLegend" class="fieldDescription" style="margin-bottom:1.5em;"></div>
|
|
|
|
<details style="margin-bottom:1.5em;">
|
|
<summary style="cursor:pointer;">By media type</summary>
|
|
<div id="JRayCoverageByType" style="margin-top:0.5em;"></div>
|
|
</details>
|
|
<details style="margin-bottom:1.5em;">
|
|
<summary style="cursor:pointer;">By genre</summary>
|
|
<div id="JRayCoverageByGenre" style="margin-top:0.5em;"></div>
|
|
</details>
|
|
|
|
<h2 style="margin-top:2em;">Prioritise / ignore rules</h2>
|
|
<p class="fieldDescription">
|
|
Shape the work-discovery API (<code>/Plugins/JRay/Tasks/Pending</code>).
|
|
<strong>Ignore</strong> hides matching items from extraction workers;
|
|
<strong>Prioritise</strong> pushes them to the front of the queue. A more
|
|
specific rule wins (item > series > genre). Setting a
|
|
rule for a target that already has one replaces it, so nothing can be both
|
|
prioritised and ignored.
|
|
</p>
|
|
|
|
<div style="display:flex;flex-wrap:wrap;gap:0.5em;align-items:flex-end;margin-bottom:1em;">
|
|
<div class="selectContainer" style="margin:0;">
|
|
<label class="selectLabel" for="JRayRuleScope">Scope</label>
|
|
<select id="JRayRuleScope" is="emby-select">
|
|
<option value="Genre">Genre</option>
|
|
<option value="Series">Series</option>
|
|
<option value="Item">Item</option>
|
|
</select>
|
|
</div>
|
|
<div id="JRayItemSearchContainer" class="inputContainer" style="margin:0;flex:1;min-width:14em;display:none;">
|
|
<label class="inputLabel inputLabelUnfocused" for="JRayItemSearch">Search title</label>
|
|
<input id="JRayItemSearch" type="text" is="emby-input" placeholder="Type a movie or episode name…" />
|
|
</div>
|
|
<div class="selectContainer" style="margin:0;flex:1;min-width:14em;">
|
|
<label class="selectLabel" for="JRayRuleValue">Target</label>
|
|
<select id="JRayRuleValue" is="emby-select"></select>
|
|
</div>
|
|
<div class="selectContainer" style="margin:0;">
|
|
<label class="selectLabel" for="JRayRuleAction">Action</label>
|
|
<select id="JRayRuleAction" is="emby-select">
|
|
<option value="Ignore">Ignore</option>
|
|
<option value="Prioritise">Prioritise</option>
|
|
</select>
|
|
</div>
|
|
<button id="JRayAddRule" is="emby-button" type="button" class="raised emby-button">
|
|
<span>Add rule</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div id="JRayRulesList"></div>
|
|
</div>
|
|
</div>
|
|
<script type="text/javascript">
|
|
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, '&').replace(/</g, '<')
|
|
.replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
// ---- Coverage ----
|
|
|
|
var JRayCoverColors = {
|
|
covered: '#4caf50',
|
|
prioritised: '#ff9800',
|
|
pending: '#9e9e9e',
|
|
ignored: 'rgba(127,127,127,0.35)'
|
|
};
|
|
|
|
function renderBar(el, counts) {
|
|
el.innerHTML = '';
|
|
var order = [
|
|
['covered', counts.covered],
|
|
['prioritised', counts.prioritised],
|
|
['pending', counts.pending - counts.prioritised],
|
|
['ignored', counts.ignored]
|
|
];
|
|
order.forEach(function (seg) {
|
|
if (counts.total <= 0 || seg[1] <= 0) { return; }
|
|
var d = document.createElement('div');
|
|
d.style.background = JRayCoverColors[seg[0]];
|
|
d.style.width = (100 * seg[1] / counts.total) + '%';
|
|
el.appendChild(d);
|
|
});
|
|
}
|
|
|
|
function pct(counts) {
|
|
var denom = counts.total - counts.ignored;
|
|
if (denom <= 0) { return '—'; }
|
|
return Math.round(100 * counts.covered / denom) + '%';
|
|
}
|
|
|
|
function renderBreakdown(el, rows) {
|
|
if (!rows || !rows.length) {
|
|
el.textContent = 'Nothing to show.';
|
|
return;
|
|
}
|
|
var html = '<table style="width:100%;border-collapse:collapse;">' +
|
|
'<thead><tr style="text-align:left;">' +
|
|
'<th>Name</th><th>Covered</th><th>Pending</th><th>Ignored</th><th>Total</th><th>%</th>' +
|
|
'</tr></thead><tbody>';
|
|
rows.forEach(function (r) {
|
|
var c = r.counts;
|
|
html += '<tr>' +
|
|
'<td>' + escapeHtml(r.label) + '</td>' +
|
|
'<td>' + c.covered + '</td>' +
|
|
'<td>' + c.pending + (c.prioritised ? ' (' + c.prioritised + ' ★)' : '') + '</td>' +
|
|
'<td>' + c.ignored + '</td>' +
|
|
'<td>' + c.total + '</td>' +
|
|
'<td>' + pct(c) + '</td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
el.innerHTML = html;
|
|
}
|
|
|
|
function loadCoverage() {
|
|
document.querySelector('#JRayCoverageSummary').textContent = 'Loading…';
|
|
jrayApi('Coverage').then(function (report) {
|
|
var t = report.total;
|
|
document.querySelector('#JRayCoverageSummary').innerHTML =
|
|
'<strong>' + pct(t) + '</strong> covered — ' +
|
|
t.covered + ' of ' + (t.total - t.ignored) + ' items processed' +
|
|
(t.ignored ? ' (' + t.ignored + ' ignored)' : '') + '.';
|
|
renderBar(document.querySelector('#JRayCoverageBar'), t);
|
|
document.querySelector('#JRayCoverageLegend').innerHTML =
|
|
'<span style="color:' + JRayCoverColors.covered + '">■</span> covered ' +
|
|
'<span style="color:' + JRayCoverColors.prioritised + '">■</span> prioritised ' +
|
|
'<span style="color:' + JRayCoverColors.pending + '">■</span> pending ' +
|
|
'<span>▨</span> ignored';
|
|
renderBreakdown(document.querySelector('#JRayCoverageByType'), report.by_media_type);
|
|
renderBreakdown(document.querySelector('#JRayCoverageByGenre'), report.by_genre);
|
|
});
|
|
}
|
|
|
|
// ---- Rules ----
|
|
|
|
var JRayOptionCache = { Genre: null, Series: null };
|
|
|
|
function loadOptionsFor(scope) {
|
|
if (JRayOptionCache[scope]) {
|
|
return Promise.resolve(JRayOptionCache[scope]);
|
|
}
|
|
var path = scope === 'Series' ? 'Coverage/Series' : 'Coverage/Genres';
|
|
return jrayApi(path).then(function (opts) {
|
|
JRayOptionCache[scope] = opts;
|
|
return opts;
|
|
});
|
|
}
|
|
|
|
function fillSelect(select, opts, emptyText) {
|
|
if (!opts.length) {
|
|
select.innerHTML = '<option value="">' + emptyText + '</option>';
|
|
return;
|
|
}
|
|
select.innerHTML = opts.map(function (o) {
|
|
return '<option value="' + escapeHtml(o.value) + '">' + escapeHtml(o.label) + '</option>';
|
|
}).join('');
|
|
}
|
|
|
|
function populateValueSelect() {
|
|
var scope = document.querySelector('#JRayRuleScope').value;
|
|
var select = document.querySelector('#JRayRuleValue');
|
|
var isItem = scope === 'Item';
|
|
document.querySelector('#JRayItemSearchContainer').style.display = isItem ? '' : 'none';
|
|
|
|
if (isItem) {
|
|
// The target dropdown is filled from a live search instead of a full list.
|
|
select.innerHTML = '<option value="">Type a title to search…</option>';
|
|
return;
|
|
}
|
|
|
|
select.innerHTML = '<option value="">Loading…</option>';
|
|
loadOptionsFor(scope).then(function (opts) {
|
|
fillSelect(select, opts, '(none found)');
|
|
});
|
|
}
|
|
|
|
var JRayItemSearchTimer = null;
|
|
function onItemSearch() {
|
|
var term = document.querySelector('#JRayItemSearch').value.trim();
|
|
var select = document.querySelector('#JRayRuleValue');
|
|
clearTimeout(JRayItemSearchTimer);
|
|
if (!term) {
|
|
select.innerHTML = '<option value="">Type a title to search…</option>';
|
|
return;
|
|
}
|
|
select.innerHTML = '<option value="">Searching…</option>';
|
|
JRayItemSearchTimer = setTimeout(function () {
|
|
jrayApi('Coverage/Items?search=' + encodeURIComponent(term)).then(function (opts) {
|
|
fillSelect(select, opts, '(no matches)');
|
|
});
|
|
}, 300);
|
|
}
|
|
|
|
function labelForRule(rule) {
|
|
if (rule.label) { return rule.label; }
|
|
var cache = JRayOptionCache[rule.scope];
|
|
if (cache) {
|
|
var hit = cache.filter(function (o) { return o.value === rule.value; })[0];
|
|
if (hit) { return hit.label; }
|
|
}
|
|
return rule.value;
|
|
}
|
|
|
|
function loadRules() {
|
|
jrayApi('Policy/Rules').then(function (rules) {
|
|
var el = document.querySelector('#JRayRulesList');
|
|
if (!rules.length) {
|
|
el.innerHTML = '<p class="fieldDescription">No rules yet.</p>';
|
|
return;
|
|
}
|
|
var html = '<table style="width:100%;border-collapse:collapse;">' +
|
|
'<thead><tr style="text-align:left;"><th>Action</th><th>Scope</th><th>Target</th><th></th></tr></thead><tbody>';
|
|
rules.forEach(function (r) {
|
|
var badge = r.action === 'Prioritise'
|
|
? '<span style="color:' + JRayCoverColors.prioritised + '">★ Prioritise</span>'
|
|
: '<span>⦸ Ignore</span>';
|
|
html += '<tr>' +
|
|
'<td>' + badge + '</td>' +
|
|
'<td>' + escapeHtml(r.scope) + '</td>' +
|
|
'<td>' + escapeHtml(labelForRule(r)) + '</td>' +
|
|
'<td><button is="emby-button" type="button" class="emby-button jray-del" ' +
|
|
'data-scope="' + escapeHtml(r.scope) + '" data-value="' + escapeHtml(r.value) + '">Remove</button></td>' +
|
|
'</tr>';
|
|
});
|
|
html += '</tbody></table>';
|
|
el.innerHTML = html;
|
|
|
|
Array.prototype.forEach.call(el.querySelectorAll('.jray-del'), function (btn) {
|
|
btn.addEventListener('click', function () {
|
|
var q = 'Policy/Rules?scope=' + encodeURIComponent(btn.getAttribute('data-scope')) +
|
|
'&value=' + encodeURIComponent(btn.getAttribute('data-value'));
|
|
jrayApi(q, 'DELETE').then(function () {
|
|
loadRules();
|
|
loadCoverage();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
}
|
|
|
|
function addRule() {
|
|
var scope = document.querySelector('#JRayRuleScope').value;
|
|
var valueSelect = document.querySelector('#JRayRuleValue');
|
|
var value = valueSelect.value;
|
|
if (!value) { return; }
|
|
var rule = {
|
|
scope: scope,
|
|
value: value,
|
|
action: document.querySelector('#JRayRuleAction').value,
|
|
label: valueSelect.options[valueSelect.selectedIndex].text
|
|
};
|
|
jrayApi('Policy/Rules', 'PUT', rule).then(function () {
|
|
loadRules();
|
|
loadCoverage();
|
|
});
|
|
}
|
|
|
|
document.querySelector('#JRayConfigPage')
|
|
.addEventListener('pageshow', function() {
|
|
Dashboard.showLoadingMsg();
|
|
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();
|
|
});
|
|
|
|
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>
|