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:
flan
2026-09-19 22:29:58 +00:00
commit 997c257442
12 changed files with 917 additions and 0 deletions
+6
View File
@@ -0,0 +1,6 @@
bin/
obj/
*.user
.vs/
.idea/
*.zip
+39
View File
@@ -0,0 +1,39 @@
# Changelog
All notable changes to this project are documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [1.0.0] - 2026-09-19
### Added
- Rating repair. A rating string Jellyfin cannot parse is not merely untidy: it
takes the same code path as "unrated", which stays visible to every profile
including one with an age ceiling set. Foreign board ratings are remapped to
their documented US equivalents (GP to PG, TP and L to TV-G, B to TV-PG, B-15
and VM14 and M to TV-14, NC16 and M18 and R21 to TV-MA, Passed to Approved,
and the plain typo PG13 to PG-13). The repaired value is added to the item's
locked fields so a later provider refresh cannot undo it.
- Ratings are never inferred from genre, synopsis or any other content signal.
A wrong guess fails open, which is the single failure mode parental controls
exist to prevent. Anything unmappable is reported as a warning for a human to
map, and left alone.
- Audience tagging. Applies a configurable tag (default `grandma`) to titles
that pass a genre and rating rule, so a user account can be narrowed with
Allowed Tags. Because Allowed Tags fails closed, new content is invisible to
that account until tagged, which is why this runs on a schedule rather than
once.
- A curation ledger at `tagged-ids.txt` in the plugin data folder. An item whose
id is in the ledger but which no longer carries the tag was untagged by a
person on purpose, and is never tagged again. Without it every run would
silently undo manual curation.
- Runs as a Jellyfin scheduled task, daily at 05:00 by default, configurable
from the Scheduled Tasks screen.
[1.0.0]: https://git.arch.fyi/flan/jellyfin-audience/releases/tag/v1.0.0
+145
View File
@@ -0,0 +1,145 @@
using System.Collections.Frozen;
using Jellyfin.Plugin.Audience.Configuration;
using MediaBrowser.Controller.Entities;
namespace Jellyfin.Plugin.Audience;
/// <summary>
/// The selection and rating rules, kept free of Jellyfin service dependencies so they
/// can be reasoned about and tested on their own.
/// </summary>
public static class AudienceRules
{
/// <summary>
/// Ratings Jellyfin cannot parse, mapped to the US rating that means the same thing.
/// </summary>
/// <remarks>
/// Every entry is a documented equivalence between national classification boards,
/// NOT a judgement about content. Guessing a rating from genre or synopsis is refused
/// on purpose: a wrong guess fails OPEN, which is the exact failure mode parental
/// controls exist to prevent.
///
/// The mapping cuts both ways. It hides adult Singapore-rated titles that were
/// previously visible to every account, and it rescues genuinely all-ages titles
/// (Spain's TP, Brazil's L) that would otherwise be blocked as unrated.
/// </remarks>
public static readonly FrozenDictionary<string, string> RatingRemap =
new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase)
{
["GP"] = "PG", // US MPAA 1970-72, the direct predecessor of PG
["PG13"] = "PG-13", // plain typo for PG-13
["M"] = "TV-14", // Australia, Mature, recommended 15+
["TP"] = "TV-G", // Spain, Todos los Publicos, all ages
["L"] = "TV-G", // Brazil, Livre, all ages
["B"] = "TV-PG", // Mexico, 12+
["B-15"] = "TV-14", // Mexico, 15+
["VM14"] = "TV-14", // Italy, Vietato ai Minori di 14
["NC16"] = "TV-MA", // Singapore, 16+
["M18"] = "TV-MA", // Singapore, 18+
["R21"] = "TV-MA", // Singapore, 21+
["PASSED"] = "Approved" // pre-1968 US Production Code seal
}.ToFrozenDictionary(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Genres that disqualify an item outright.
/// </summary>
public static readonly FrozenSet<string> HarshGenres =
new[]
{
"Horror", "Thriller", "War", "Science Fiction", "Sci-Fi & Fantasy",
"Animation", "Anime", "Action", "Action & Adventure", "Crime",
"Reality", "Talk", "News"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Genres an item must have at least one of to qualify.
/// </summary>
public static readonly FrozenSet<string> GentleGenres =
new[]
{
"Comedy", "Drama", "Romance", "Family", "Music", "Western",
"Mystery", "Documentary", "History", "Soap", "Kids"
}.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Values that Jellyfin itself treats as "no rating".
/// </summary>
private static readonly FrozenSet<string> UnratedValues =
new[] { "n/a", "unrated", "not rated", "nr" }
.ToFrozenSet(StringComparer.OrdinalIgnoreCase);
/// <summary>
/// Returns the replacement rating for an unparseable value, or null to leave it alone.
/// </summary>
public static string? RemapRating(string? officialRating)
{
if (string.IsNullOrWhiteSpace(officialRating))
{
return null;
}
return RatingRemap.TryGetValue(officialRating.Trim(), out var mapped)
? mapped
: null;
}
/// <summary>
/// Returns true when the value is one Jellyfin resolves to "unrated".
/// </summary>
public static bool IsUnratedValue(string? officialRating)
=> string.IsNullOrWhiteSpace(officialRating)
|| UnratedValues.Contains(officialRating.Trim());
/// <summary>
/// Decides whether an item belongs in the reduced library.
/// </summary>
/// <param name="item">The item under test.</param>
/// <param name="ratingScore">
/// The score from Jellyfin's localisation manager, or null when the rating is absent
/// or unrecognised. Passed in rather than resolved here so this stays dependency-free.
/// </param>
/// <param name="config">Plugin settings.</param>
public static bool WantsTag(BaseItem item, int? ratingScore, PluginConfiguration config)
{
var genres = item.Genres;
if (genres is null || genres.Length == 0)
{
return false;
}
foreach (var genre in genres)
{
if (HarshGenres.Contains(genre))
{
return false;
}
}
var hasGentle = false;
foreach (var genre in genres)
{
if (GentleGenres.Contains(genre))
{
hasGentle = true;
break;
}
}
if (!hasGentle)
{
return false;
}
// A usable rating is judged on the rating. Anything at or below the ceiling is in.
if (ratingScore.HasValue)
{
return ratingScore.Value <= config.MaxRatingScore;
}
// No usable rating: fall back to reputation. This is what keeps the older
// classics, which are exactly what this audience wants and which TMDB
// frequently has no US certification for.
return item.CommunityRating.HasValue
&& item.CommunityRating.Value >= config.MinCommunityRatingWhenUnrated;
}
}
+204
View File
@@ -0,0 +1,204 @@
using Jellyfin.Data.Enums;
using MediaBrowser.Controller.Entities;
using MediaBrowser.Controller.Library;
using MediaBrowser.Model.Entities;
using MediaBrowser.Model.Globalization;
using MediaBrowser.Model.Tasks;
using Microsoft.Extensions.Logging;
namespace Jellyfin.Plugin.Audience;
/// <summary>
/// Nightly maintenance: repair unparseable parental ratings, then keep the audience
/// tag current as new content arrives.
/// </summary>
/// <remarks>
/// Running in-process is the whole point. The REST equivalent (POST /Items/{id}) carries
/// three traps that do not exist here:
/// 1. Name, Overview, ProductionYear, OfficialRating and CustomRating are assigned
/// unconditionally, so omitting one WIPES it.
/// 2. IsLocked = request.LockData ?? false, so omitting LockData silently unlocks.
/// 3. TrickplayInfoDto serializes but does not deserialize in 12.0, so a read-modify-write
/// round trip returns HTTP 500 for any item with trickplay thumbnails. The signature is
/// misleading: every Movie fails while every Series succeeds, which reads like a
/// permissions problem and is not.
/// Mutating the entity and calling UpdateToRepositoryAsync touches only what was changed,
/// and needs no API key, no drop-file and no container.
/// </remarks>
public class AudienceTask : IScheduledTask
{
private readonly ILibraryManager _libraryManager;
private readonly ILocalizationManager _localization;
private readonly ILogger<AudienceTask> _logger;
/// <summary>
/// Initializes a new instance of the <see cref="AudienceTask"/> class.
/// </summary>
public AudienceTask(
ILibraryManager libraryManager,
ILocalizationManager localization,
ILogger<AudienceTask> logger)
{
_libraryManager = libraryManager;
_localization = localization;
_logger = logger;
}
/// <inheritdoc />
public string Name => "Repair ratings and apply audience tags";
/// <inheritdoc />
public string Key => "AudienceMaintain";
/// <inheritdoc />
public string Description =>
"Rewrites parental ratings Jellyfin cannot parse to their US equivalent, then " +
"applies the audience tag to newly added titles that match the selection rules.";
/// <inheritdoc />
public string Category => "Audience";
/// <inheritdoc />
public IEnumerable<TaskTriggerInfo> GetDefaultTriggers()
{
yield return new TaskTriggerInfo
{
Type = TaskTriggerInfoType.DailyTrigger,
TimeOfDayTicks = TimeSpan.FromHours(5).Ticks
};
}
/// <inheritdoc />
public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken)
{
var config = Plugin.Instance?.Configuration;
if (config is null || !config.Enabled)
{
_logger.LogInformation("Audience task is disabled; nothing to do.");
return;
}
var items = _libraryManager.GetItemList(new InternalItemsQuery
{
IncludeItemTypes = new[] { BaseItemKind.Movie, BaseItemKind.Series },
IsVirtualItem = false,
Recursive = true
});
_logger.LogInformation("Audience: examining {Count} titles.", items.Count);
var ledger = LedgerStore.Load();
var repaired = 0;
var tagged = 0;
var unmapped = new List<string>();
var processed = 0;
foreach (var item in items)
{
cancellationToken.ThrowIfCancellationRequested();
var changed = false;
if (config.RepairRatings)
{
var remapped = AudienceRules.RemapRating(item.OfficialRating);
if (remapped is not null)
{
_logger.LogInformation(
"Audience: rating {Old} -> {New} on {Title}.",
item.OfficialRating,
remapped,
item.Name);
item.OfficialRating = remapped;
// Lock it so a later provider refresh cannot reintroduce the
// unparseable value. Nothing is forfeited: the provider had no
// usable US rating for these titles in the first place.
if (!item.LockedFields.Contains(MetadataField.OfficialRating))
{
item.LockedFields = item.LockedFields
.Append(MetadataField.OfficialRating)
.Distinct()
.ToArray();
}
repaired++;
changed = true;
}
else if (!AudienceRules.IsUnratedValue(item.OfficialRating)
&& ResolveScore(item.OfficialRating) is null)
{
// A rating that is neither recognised nor in the remap table. Report it
// rather than guess: it is currently taking the unrated path and is
// therefore visible to every profile.
unmapped.Add($"{item.OfficialRating} ({item.Name})");
}
}
if (config.ApplyTags)
{
var hasTag = item.Tags.Contains(config.TagName, StringComparer.OrdinalIgnoreCase);
var wants = AudienceRules.WantsTag(item, ResolveScore(item.OfficialRating), config);
var seenBefore = ledger.Contains(item.Id);
// An id in the ledger without the tag was untagged by a human on purpose.
// Re-applying it would quietly undo their curation every single night.
var suppressed = config.RespectManualUntag && seenBefore && !hasTag;
if (wants && !hasTag && !suppressed)
{
item.Tags = item.Tags.Append(config.TagName).ToArray();
ledger.Add(item.Id);
tagged++;
changed = true;
}
}
if (changed)
{
await item.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken)
.ConfigureAwait(false);
}
processed++;
progress.Report(processed * 100.0 / items.Count);
}
LedgerStore.Save(ledger);
_logger.LogInformation(
"Audience: repaired {Repaired} ratings, applied {Tagged} tags.",
repaired,
tagged);
if (unmapped.Count > 0)
{
// Deliberately a warning. These are invisible to parental controls until a
// human adds a mapping, so they must not scroll past as information.
_logger.LogWarning(
"Audience: {Count} rating value(s) are neither recognised by Jellyfin nor " +
"in the remap table, so they resolve as unrated and stay visible to every " +
"profile: {Values}",
unmapped.Count,
string.Join("; ", unmapped.Distinct()));
}
}
/// <summary>
/// Resolves a rating string to Jellyfin's numeric score, or null when it does not parse.
/// </summary>
private int? ResolveScore(string? officialRating)
{
if (string.IsNullOrWhiteSpace(officialRating))
{
return null;
}
// Jellyfin 10.11 replaced the flat rating table (us.csv, string -> int) with a scored one
// (us.json), and GetRatingLevel went with it. GetRatingScore returns a ParentalRatingScore
// carrying Score plus SubScore; SubScore is what separates R (17/0) from NC-17 and TV-MA
// (17/1). Only Score is needed here, because the tag rule is a plain ceiling.
return _localization.GetRatingScore(officialRating)?.Score;
}
}
@@ -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>
@@ -0,0 +1,30 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<RootNamespace>Jellyfin.Plugin.Audience</RootNamespace>
<AssemblyVersion>1.0.0.0</AssemblyVersion>
<FileVersion>1.0.0.0</FileVersion>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
</PropertyGroup>
<!--
Version choices are not arbitrary. jellyfin-placard ships with exactly this
combination (net9.0 + Jellyfin.Controller 10.11.11) and is installed and
Active on Serber, which runs Jellyfin 12.0.0, with its scheduled task
registered and completing runs. A 10.11-targeted plugin therefore loads on
12.0. Do not "upgrade" these to chase the server version without first
confirming a newer Jellyfin.Controller package actually exists and that the
resulting build still loads.
-->
<ItemGroup>
<PackageReference Include="Jellyfin.Controller" Version="10.11.11" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Configuration\configPage.html" />
</ItemGroup>
</Project>
+78
View File
@@ -0,0 +1,78 @@
using System.Globalization;
namespace Jellyfin.Plugin.Audience;
/// <summary>
/// Remembers every item this plugin has ever tagged.
/// </summary>
/// <remarks>
/// This is what makes manual curation stick. The selection rules are a guess; the person
/// reviewing the result will remove titles that do not belong. Without a record of what was
/// tagged, the next run cannot tell "never seen, should tag" apart from "tagged once, human
/// removed it", and would re-apply the tag every night forever.
/// </remarks>
public static class LedgerStore
{
private const string FileName = "tagged-ids.txt";
private static string LedgerPath =>
Path.Combine(
Plugin.Instance?.DataFolderPath
?? throw new InvalidOperationException("Plugin instance is not initialised."),
FileName);
/// <summary>
/// Loads the set of previously tagged item ids. Returns an empty set on first run.
/// </summary>
public static HashSet<Guid> Load()
{
var result = new HashSet<Guid>();
var path = LedgerPath;
if (!File.Exists(path))
{
return result;
}
foreach (var line in File.ReadLines(path))
{
var trimmed = line.Trim();
if (trimmed.Length == 0)
{
continue;
}
if (Guid.TryParse(trimmed, out var id))
{
result.Add(id);
}
}
return result;
}
/// <summary>
/// Writes the ledger atomically, so an interrupted run cannot truncate it.
/// </summary>
/// <remarks>
/// A truncated ledger is not a cosmetic problem: every id lost from it becomes an item
/// the next run believes it has never seen, which silently re-applies tags a human
/// deliberately removed.
/// </remarks>
public static void Save(HashSet<Guid> ids)
{
var path = LedgerPath;
var directory = Path.GetDirectoryName(path);
if (!string.IsNullOrEmpty(directory))
{
Directory.CreateDirectory(directory);
}
var temp = path + ".tmp";
File.WriteAllLines(
temp,
ids.Select(id => id.ToString("N", CultureInfo.InvariantCulture)));
File.Move(temp, path, overwrite: true);
}
}
+52
View File
@@ -0,0 +1,52 @@
using System.Globalization;
using Jellyfin.Plugin.Audience.Configuration;
using MediaBrowser.Common.Configuration;
using MediaBrowser.Common.Plugins;
using MediaBrowser.Model.Plugins;
using MediaBrowser.Model.Serialization;
namespace Jellyfin.Plugin.Audience;
/// <summary>
/// Plugin entry point.
/// </summary>
public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages
{
/// <summary>
/// Initializes a new instance of the <see cref="Plugin"/> class.
/// </summary>
public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
: base(applicationPaths, xmlSerializer)
{
Instance = this;
}
/// <summary>
/// Gets the current plugin instance.
/// </summary>
public static Plugin? Instance { get; private set; }
/// <inheritdoc />
public override string Name => "Audience";
/// <inheritdoc />
public override string Description =>
"Repairs parental ratings Jellyfin cannot parse, and maintains an audience tag " +
"so a user can be given a smaller, gentler library.";
/// <inheritdoc />
public override Guid Id => new("c7e4a1f2-9b3d-4a6e-8c51-3f7d2b9e04a6");
/// <inheritdoc />
public IEnumerable<PluginPageInfo> GetPages()
{
yield return new PluginPageInfo
{
Name = Name,
EmbeddedResourcePath = string.Format(
CultureInfo.InvariantCulture,
"{0}.Configuration.configPage.html",
GetType().Namespace)
};
}
}
+13
View File
@@ -0,0 +1,13 @@
{
"category": "Metadata",
"guid": "c7e4a1f2-9b3d-4a6e-8c51-3f7d2b9e04a6",
"name": "Audience",
"description": "Repairs ratings Jellyfin cannot parse and maintains an audience tag for filtered user accounts.",
"overview": "Rating repair and audience tagging",
"owner": "flan",
"targetAbi": "10.11.0.0",
"framework": "net9.0",
"version": "1.0.0.0",
"changelog": "First release. Remaps unparseable board ratings to their US equivalents, locks the repaired value, and maintains an audience tag with a ledger that respects manual untagging.",
"timestamp": "2026-09-19T00:00:00Z"
}
+5
View File
@@ -0,0 +1,5 @@
Jellyfin.Plugin.Audience
Portions of this project were written with assistance from Claude, an AI
assistant made by Anthropic. All code was reviewed, built and verified against
a live Jellyfin server before release.
+109
View File
@@ -0,0 +1,109 @@
# Jellyfin Audience
A Jellyfin plugin that repairs ratings the server cannot parse, and maintains an
audience tag so a user account can be narrowed to a manageable subset of a large
library.
It runs in-process as a scheduled task. It needs no API key, no credential file
and no external service.
## Why it exists
### Ratings Jellyfin cannot parse are a hole in parental controls
Jellyfin resolves an `OfficialRating` string to a numeric score. When it cannot,
the item takes the same path as an unrated one, and unrated items are **visible
to everyone by default** unless an account explicitly blocks unrated content.
That is not a theoretical problem. A real library scanned for this contained
Singapore board ratings (`M18`, `NC16`, `R21`) on adult titles. Jellyfin could
not parse any of them, so no age ceiling would have hidden them. It cuts the
other way too: `TP` (Spain) and `L` (Brazil) both mean "all ages", and titles
carrying them were being hidden from children as though unrated.
This plugin remaps those strings to their documented US equivalents and locks
the result.
### Allowed Tags fails closed
Narrowing an account with Allowed Tags is the right mechanism, but it is a
whitelist: anything without the tag is invisible. A library that keeps growing
would leave that account frozen at whatever was tagged on the day it was set up.
So the tagging pass has to run on a schedule, not once.
## What it does not do
It does not guess a rating from genre, synopsis, or anything else about the
content. A wrong guess fails open, and failing open is the one outcome parental
controls exist to prevent. Ratings it cannot map are logged as warnings and left
untouched, for a person to decide.
## Configuration
Dashboard, then Plugins, then Audience.
| Setting | Default | Meaning |
|---|---|---|
| `Enabled` | on | Master switch for the scheduled task |
| `RepairRatings` | on | Remap unparseable board ratings |
| `ApplyTags` | on | Maintain the audience tag |
| `TagName` | `grandma` | Tag applied to matching titles |
| `MaxRatingScore` | `14` | Rating ceiling for tagging. Inclusive |
| `MinCommunityRatingWhenUnrated` | `6.0` | Community-rating floor for titles with no usable rating |
| `RespectManualUntag` | on | Honour the ledger, so manual untagging sticks |
The US rating ladder is `0` G and TV-G, `7` TV-Y7, `10` PG and TV-PG, `13`
PG-13, `14` TV-14, `17` R and TV-MA. Note that Jellyfin 10.11 rescored `TV-PG`
from 13 to 10, so a ceiling carried over from an older install will not behave
the way it used to.
`MinCommunityRatingWhenUnrated` is a weak gate on obscure titles, where a
perfect score can rest on a handful of votes. It is a starting filter, not a
verdict.
## Curating the result
The rule is a first pass, not an opinion. To remove something, untag it in the
Jellyfin UI. The ledger records that the item was tagged once, so the next run
sees a deliberate removal and leaves it alone.
Turning off `RespectManualUntag` makes every run re-apply the rule, which undoes
curation. It exists for the case where you want to reset and start again.
## Building
Needs the .NET 9 SDK. On a machine where it is a user-local install rather than
a system package, it will not be on the PATH of a non-interactive shell:
```bash
export DOTNET_ROOT="$HOME/.dotnet"
export PATH="$DOTNET_ROOT:$PATH"
dotnet build Jellyfin.Plugin.Audience/Jellyfin.Plugin.Audience.csproj -c Release
```
The output is `Jellyfin.Plugin.Audience/bin/Release/net9.0/Jellyfin.Plugin.Audience.dll`.
## Installing
Copy the DLL and `meta.json` into a folder named `Audience_1.0.0.0` inside
Jellyfin's `plugins` directory, then restart the server. The plugin appears
under Dashboard, Plugins, and its task appears under Scheduled Tasks as
"Repair ratings and apply audience tags".
`targetAbi` is `10.11.0.0`. That is deliberate and is not a mistake on a 12.x
server: a plugin built against `Jellyfin.Controller` 10.11.11 loads and runs on
Jellyfin 12.0. Do not raise it without confirming the newer package exists and
the result still loads.
## Releasing
To publish through a Jellyfin plugin catalog, zip the DLL and `meta.json`, host
the zip, and add a repository manifest entry whose `sourceUrl` points at it. The
`sourceUrl` must end in `.zip`, and the `checksum` field must be the MD5 of the
actual published archive. No manifest is committed here, because a manifest
carrying a placeholder checksum or a URL with nothing behind it produces a
broken install for anyone who adds it.
## Licence
GPL-3.0.