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
+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"
}