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.
79 lines
2.3 KiB
C#
79 lines
2.3 KiB
C#
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);
|
|
}
|
|
}
|