Add Audience plugin: rating repair and audience tagging
Jellyfin treats a rating string it cannot parse the same as no rating at all: the item takes the unrated path and stays visible to every profile, including one with an age ceiling set. This library carried eighteen such items. Six were Singapore adult ratings (M18, NC16, R21) that no age ceiling would have caught, and two (TP, L) mean "all ages" and were being withheld from children for the opposite reason. The task remaps those strings to their US equivalents using published board equivalences, and locks the repaired value against provider refresh. Ratings are never inferred from genre or synopsis; an unmapped string is reported as a warning instead. A wrong guess there fails open, which is the one outcome parental controls exist to prevent. The audience tag backs a per-user Allowed Tags whitelist. That check fails closed, so new content is invisible to the filtered account until it is tagged, which is why the work has to recur rather than run once. A ledger records every id the task has tagged; an id present in the ledger but no longer carrying the tag was untagged deliberately and is never tagged again. Running in-process as a scheduled task means no API key, no credential file and no container. It also sidesteps three defects in the 12.0 REST item-update path, which assigns Name, Overview, ProductionYear, OfficialRating and CustomRating unconditionally, silently unlocks an item when LockData is omitted, and rejects its own serialized Trickplay block on the way back in.
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
using MediaBrowser.Model.Plugins;
|
||||
|
||||
namespace Jellyfin.Plugin.Audience.Configuration;
|
||||
|
||||
/// <summary>
|
||||
/// Settings for the Audience plugin. Defaults reproduce the selection that was
|
||||
/// applied by hand to this library: 561 of 2778 titles tagged for a viewer who
|
||||
/// finds the full library overwhelming.
|
||||
/// </summary>
|
||||
public class PluginConfiguration : BasePluginConfiguration
|
||||
{
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the scheduled task does anything at all.
|
||||
/// </summary>
|
||||
public bool Enabled { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether ratings Jellyfin cannot parse are rewritten
|
||||
/// to their US equivalent.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// This is the safety-relevant half. A rating string Jellyfin cannot parse takes the
|
||||
/// same code path as "unrated", which means it stays VISIBLE to every profile,
|
||||
/// including one with an age ceiling set.
|
||||
/// </remarks>
|
||||
public bool RepairRatings { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether the audience tag is applied to new content.
|
||||
/// </summary>
|
||||
public bool ApplyTags { get; set; } = true;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the tag applied to selected items. This is the string that goes in the
|
||||
/// target user's "Allowed tags" list.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Lower case with no spaces is safest: Jellyfin normalises tags through
|
||||
/// GetCleanValue() and then compares with StringComparer.Ordinal.
|
||||
/// </remarks>
|
||||
public string TagName { get; set; } = "grandma";
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the highest parental-rating score an item may carry and still be tagged.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Scores come from Jellyfin's own rating table, so they follow the server's
|
||||
/// metadata country. For US: G/TV-G/TV-Y = 0, TV-Y7 = 7, PG/TV-PG = 10,
|
||||
/// PG-13 = 13, TV-14 = 14, R/NC-17/TV-MA = 17. Note TV-PG was rescored from
|
||||
/// 13 to 10 in the 10.11 rating rewrite.
|
||||
/// </remarks>
|
||||
public int MaxRatingScore { get; set; } = 14;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets the minimum community rating for an item with no usable parental rating.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// Unrated items are admitted only on reputation, because there is no rating to judge
|
||||
/// them by. This is deliberately not a safety gate: a viewer given an allow-list is an
|
||||
/// adult, and child profiles are protected by MaxParentalRating plus BlockUnratedItems
|
||||
/// instead. Be aware this floor is weak on obscure titles, where a perfect score can
|
||||
/// rest on a handful of votes.
|
||||
/// </remarks>
|
||||
public double MinCommunityRatingWhenUnrated { get; set; } = 6.0;
|
||||
|
||||
/// <summary>
|
||||
/// Gets or sets a value indicating whether an item untagged by hand is left alone.
|
||||
/// </summary>
|
||||
/// <remarks>
|
||||
/// The task keeps a ledger of every id it has ever tagged. If an id is in the ledger
|
||||
/// but no longer carries the tag, a human removed it on purpose. Without this, every
|
||||
/// nightly run would silently undo curation.
|
||||
/// </remarks>
|
||||
public bool RespectManualUntag { get; set; } = true;
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<title>Audience</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="AudienceConfigPage"
|
||||
data-role="page"
|
||||
class="page type-interior pluginConfigurationPage"
|
||||
data-require="emby-input,emby-button,emby-checkbox">
|
||||
<div data-role="content">
|
||||
<div class="content-primary">
|
||||
<form id="AudienceConfigForm">
|
||||
|
||||
<div class="verticalSection">
|
||||
<h2 class="sectionTitle">Audience</h2>
|
||||
<p class="fieldDescription">
|
||||
Repairs ratings Jellyfin cannot parse, and tags the items a
|
||||
reduced-library profile should see.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="Enabled" />
|
||||
<span>Enable the scheduled task</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
When unchecked the task still appears in Scheduled Tasks but does nothing.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3 class="sectionTitle">Rating repair</h3>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="RepairRatings" />
|
||||
<span>Repair unparseable ratings</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Rewrites foreign board ratings to their US equivalent, for example
|
||||
Singapore M18 to TV-MA and Spain TP to TV-G. A rating Jellyfin cannot
|
||||
parse is treated as unrated, which means it stays visible to every
|
||||
profile including one with an age ceiling. Ratings are never guessed
|
||||
from genre or synopsis; anything unmapped is logged as a warning
|
||||
instead.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="verticalSection">
|
||||
<h3 class="sectionTitle">Audience tagging</h3>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="ApplyTags" />
|
||||
<span>Apply the audience tag</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Set a user's Allowed Tags to this tag to reduce what they see.
|
||||
Allowed Tags fails closed, so new content is invisible to that user
|
||||
until this task tags it.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="TagName">Tag name</label>
|
||||
<input is="emby-input" type="text" id="TagName" />
|
||||
<div class="fieldDescription">
|
||||
Lowercase with no spaces is safest: tag matching normalises the value
|
||||
and then compares it ordinally.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="MaxRatingScore">Maximum rating score</label>
|
||||
<input is="emby-input" type="number" id="MaxRatingScore" min="0" max="18" step="1" />
|
||||
<div class="fieldDescription">
|
||||
US ladder: 0 G and TV-G, 7 TV-Y7, 10 PG and TV-PG, 13 PG-13,
|
||||
14 TV-14, 17 R and TV-MA. Inclusive, so 14 admits TV-14 itself.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="inputContainer">
|
||||
<label class="inputLabel inputLabelUnfocused" for="MinCommunityRatingWhenUnrated">Community rating floor for unrated items</label>
|
||||
<input is="emby-input" type="number" id="MinCommunityRatingWhenUnrated" min="0" max="10" step="0.1" />
|
||||
<div class="fieldDescription">
|
||||
Unrated items are admitted only above this community rating. This is a
|
||||
weak gate on obscure titles, where a perfect score can rest on a
|
||||
handful of votes.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="checkboxContainer checkboxContainer-withDescription">
|
||||
<label class="emby-checkbox-label">
|
||||
<input is="emby-checkbox" type="checkbox" id="RespectManualUntag" />
|
||||
<span>Respect manual untagging</span>
|
||||
</label>
|
||||
<div class="fieldDescription checkboxFieldDescription">
|
||||
Remember every item this task has tagged. If the tag is later removed
|
||||
by hand, treat that as a deliberate decision and never re-apply it.
|
||||
Turning this off makes each run undo your curation.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<button is="emby-button" type="submit" class="raised button-submit block">
|
||||
<span>Save</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script type="text/javascript">
|
||||
(function () {
|
||||
var pluginId = 'c7e4a1f2-9b3d-4a6e-8c51-3f7d2b9e04a6';
|
||||
|
||||
document.querySelector('#AudienceConfigPage')
|
||||
.addEventListener('pageshow', function () {
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
document.querySelector('#Enabled').checked = config.Enabled;
|
||||
document.querySelector('#RepairRatings').checked = config.RepairRatings;
|
||||
document.querySelector('#ApplyTags').checked = config.ApplyTags;
|
||||
document.querySelector('#TagName').value = config.TagName;
|
||||
document.querySelector('#MaxRatingScore').value = config.MaxRatingScore;
|
||||
document.querySelector('#MinCommunityRatingWhenUnrated').value = config.MinCommunityRatingWhenUnrated;
|
||||
document.querySelector('#RespectManualUntag').checked = config.RespectManualUntag;
|
||||
Dashboard.hideLoadingMsg();
|
||||
});
|
||||
});
|
||||
|
||||
document.querySelector('#AudienceConfigForm')
|
||||
.addEventListener('submit', function (e) {
|
||||
e.preventDefault();
|
||||
Dashboard.showLoadingMsg();
|
||||
ApiClient.getPluginConfiguration(pluginId).then(function (config) {
|
||||
config.Enabled = document.querySelector('#Enabled').checked;
|
||||
config.RepairRatings = document.querySelector('#RepairRatings').checked;
|
||||
config.ApplyTags = document.querySelector('#ApplyTags').checked;
|
||||
config.TagName = document.querySelector('#TagName').value;
|
||||
config.MaxRatingScore = parseInt(document.querySelector('#MaxRatingScore').value, 10);
|
||||
config.MinCommunityRatingWhenUnrated = parseFloat(document.querySelector('#MinCommunityRatingWhenUnrated').value);
|
||||
config.RespectManualUntag = document.querySelector('#RespectManualUntag').checked;
|
||||
|
||||
ApiClient.updatePluginConfiguration(pluginId, config).then(function (result) {
|
||||
Dashboard.processPluginConfigurationUpdateResult(result);
|
||||
});
|
||||
});
|
||||
return false;
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user