From 7f06b05b9a4ab94032f98aa3bac4e9614ac7406f Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Thu, 6 Jun 2024 19:20:32 +0200 Subject: [PATCH] refactor: overhaul tag internals + add content ratings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Overhauled the internals for how we processed tags. We now use the tag hierarchy to filter it locally instead of the flat lists used on the Shoko Server side, which allows us to avoid magical numbers and also gives us more control when searching for tags in the hierarchy. - Custom tag support have also been overhauled to support a _fake_ hierarchy by using `/` (forward slash) as a separator between levels in the tag name. All top level custom tags without "child" tags are added to the list if custom tags have been enabled, while all tags in the second level or below are treated as if they are of the AniDB hierarchy and will follow the other enabled/disabled tag sources, e.g. if you enable "elements" tags then it will also affect any custom tags starting with "elements/". This overhauled support for custom tags also allows us to better support the next item on the list. - Added _assumed_ content ratings based on the AniDB tag hierarchy. This will use the `content indicators` tags and `elements` tags in the hierarchy to make an assumption of the content rating for the AniDB anime using the following content indicators; - TV-G - TV-Y - TV-Y7 (optionally with the FV indicator applied) - TV-PG (optionally with D, L, S, and/or V indicators applied) - TV-14 (optionally with D, L, S, and/or V indicators applied) - TV-MA (optionally with L, S, and/or V indicators applied) - XXX (only for H) We also support custom overrides through custom tags in the form of the regular expressions (regex); ```txt /^\/?target audience\/(?:TV)?[_\-]?(?:G|Y|Y7|PG|14|MA|XXX)[_\-]?(FV|D|S|L|V){1,5}$/i ``` A few examples that all resolve to the `TV-PG` rating if you can't read regex; - `target audience/TV-PG` → `TV-PG` - `Target Audience/TV-PG-DV` → `TV-PG` + `D` & `V` indicators applied - `Target Audience/TV_PG_ld` → `TV-PG` + `D` & `L` indicators applied - `/target audience/pgV` → `TV-PG` + `V` indicator applied - `/TARGET AUDIENCE/tvPGsl` → `TV-PG` + `L` & `S` indicators applied Where you will override the content rating for the series if the custom tag is applied in Shoko Server/Shoko Desktop. - Added production locations based on the AniDB tag hierarchy. This is much simpler, as it's a 1:N mapping for the relevant AniDB tags to production locations. - Overhauled the settings page to accommodate the new settings added in this commit under the Metadata section while also removing the old Tags section. In case it wasn't obvious, **THIS IS A BREAKING CHANGE**. 🙂 **Note**: TMDB content ratings and production locations will be usable when when the data is available in Shoko Server. --- .vscode/settings.json | 7 + Shokofin/API/Info/SeasonInfo.cs | 8 +- Shokofin/API/Info/ShowInfo.cs | 13 +- Shokofin/API/Models/Tag.cs | 62 +- Shokofin/API/ShokoAPIManager.cs | 207 +++-- Shokofin/Configuration/PluginConfiguration.cs | 107 ++- Shokofin/Configuration/configController.js | 495 ++++++----- Shokofin/Configuration/configPage.html | 821 ++++++++++++++---- Shokofin/ListExtensions.cs | 18 + Shokofin/Providers/EpisodeProvider.cs | 5 +- Shokofin/Providers/ImageProvider.cs | 55 +- Shokofin/Providers/MovieProvider.cs | 2 + Shokofin/Providers/SeasonProvider.cs | 4 + Shokofin/Providers/SeriesProvider.cs | 3 +- Shokofin/Utils/ContentRating.cs | 350 ++++++++ Shokofin/Utils/Ordering.cs | 9 +- Shokofin/Utils/TagFilter.cs | 269 ++++++ 17 files changed, 1951 insertions(+), 484 deletions(-) create mode 100644 Shokofin/ListExtensions.cs create mode 100644 Shokofin/Utils/ContentRating.cs create mode 100644 Shokofin/Utils/TagFilter.cs diff --git a/.vscode/settings.json b/.vscode/settings.json index c0641e12..234a5bdb 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -11,6 +11,7 @@ "automagically", "boxset", "dlna", + "ecchi", "emby", "eroge", "fanart", @@ -20,11 +21,14 @@ "imdbid", "interrobang", "jellyfin", + "josei", "jprm", + "kodomo", "koma", "linkbutton", "manhua", "manhwa", + "mina", "nfo", "nfos", "outro", @@ -32,9 +36,12 @@ "scrobble", "scrobbled", "scrobbling", + "seinen", "seiyuu", "shoko", "shokofin", + "shoujo", + "shounen", "signalr", "tmdb", "tvshow", diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index f485279e..abe58c29 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -34,10 +34,14 @@ public class SeasonInfo /// public readonly DateTime? LastImportedAt; + public readonly string? AssumedContentRating; + public readonly IReadOnlyList Tags; public readonly IReadOnlyList Genres; + public readonly IReadOnlyList ProductionLocations; + public readonly IReadOnlyList Studios; public readonly IReadOnlyList Staff; @@ -93,7 +97,7 @@ public class SeasonInfo /// public readonly IReadOnlyDictionary RelationMap; - public SeasonInfo(Series series, IEnumerable extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List episodes, List cast, List relations, string[] genres, string[] tags) + public SeasonInfo(Series series, IEnumerable extraIds, DateTime? earliestImportedAt, DateTime? lastImportedAt, List episodes, List cast, List relations, string[] genres, string[] tags, string[] productionLocations, string? contentRating) { var seriesId = series.IDs.Shoko.ToString(); var studios = cast @@ -215,8 +219,10 @@ public SeasonInfo(Series series, IEnumerable extraIds, DateTime? earlies Type = type; EarliestImportedAt = earliestImportedAt; LastImportedAt = lastImportedAt; + AssumedContentRating = contentRating; Tags = tags; Genres = genres; + ProductionLocations = productionLocations; Studios = studios; Staff = staff; RawEpisodeList = episodes; diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 7c3c4201..9fa56ac8 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -67,12 +67,6 @@ public class ShowInfo .OrderBy(s => s) .LastOrDefault(); - /// - /// Overall content rating of the show. - /// - public string? OfficialRating => - DefaultSeason.AniDB.Restricted ? "XXX" : null; - /// /// Custom rating of the show. /// @@ -107,6 +101,11 @@ public class ShowInfo /// public readonly IReadOnlyList Genres; + /// + /// All production locations from across all seasons. + /// + public readonly IReadOnlyList ProductionLocations; + /// /// All studios from across all seasons. /// @@ -179,6 +178,7 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) LastImportedAt = seasonInfo.LastImportedAt; Tags = seasonInfo.Tags; Genres = seasonInfo.Genres; + ProductionLocations = seasonInfo.ProductionLocations; Studios = seasonInfo.Studios; Staff = seasonInfo.Staff; SeasonList = new List() { seasonInfo }; @@ -253,6 +253,7 @@ public ShowInfo(Group group, List seasonList, ILogger logger, bool u LastImportedAt = seasonList.Select(seasonInfo => seasonInfo.LastImportedAt).Max(); Tags = seasonList.SelectMany(s => s.Tags).Distinct().ToArray(); Genres = seasonList.SelectMany(s => s.Genres).Distinct().ToArray(); + ProductionLocations = seasonList.SelectMany(s => s.ProductionLocations).Distinct().ToArray(); Studios = seasonList.SelectMany(s => s.Studios).Distinct().ToArray(); Staff = seasonList.SelectMany(s => s.Staff).DistinctBy(p => new { p.Type, p.Name, p.Role }).ToArray(); SeasonList = seasonList; diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index 796fbed5..52729421 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -1,6 +1,10 @@ using System; +using System.Collections.Generic; +using System.Linq; using System.Text.Json.Serialization; +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; + namespace Shokofin.API.Models; public class Tag @@ -40,7 +44,8 @@ public class Tag /// /// True if the tag is considered a spoiler for all series it appears on. /// - public bool IsSpoiler { get; set; } + [JsonPropertyName("IsSpoiler")] + public bool IsGlobalSpoiler { get; set; } /// /// True if the tag is considered a spoiler for that particular series it is @@ -51,7 +56,7 @@ public class Tag /// /// How relevant is it to the series /// - public int? Weight { get; set; } + public TagWeight? Weight { get; set; } /// /// When the tag info was last updated. @@ -63,3 +68,56 @@ public class Tag /// public string Source { get; set; } = string.Empty; } + +public class ResolvedTag : Tag +{ + private string? _fullName = null; + + public string FullName => _fullName ??= Namespace + Name; + + public bool IsParent => Children.Count is > 0; + + public bool IsWeightless => Children.Count is 0 && Weight is 0; + + /// + /// True if the tag is considered a spoiler for that particular series it is + /// set on. + /// + public new bool IsLocalSpoiler; + + /// + /// How relevant is it to the series + /// + public new TagWeight Weight; + + public string Namespace; + + public ResolvedTag? Parent; + + public IReadOnlyDictionary Children; + + public IReadOnlyDictionary RecursiveNamespacedChildren; + + public ResolvedTag(Tag tag, ResolvedTag? parent, Func?> getChildren, string ns = "/") + { + Id = tag.Id; + ParentId = parent?.Id; + Name = tag.Name; + Description = tag.Description; + IsVerified = tag.IsVerified; + IsGlobalSpoiler = tag.IsGlobalSpoiler || (parent?.IsGlobalSpoiler ?? false); + IsLocalSpoiler = tag.IsLocalSpoiler ?? parent?.IsLocalSpoiler ?? false; + Weight = tag.Weight ?? TagWeight.Weightless; + LastUpdated = tag.LastUpdated; + Source = tag.Source; + Namespace = ns; + Parent = parent; + Children = (getChildren(Source, Id) ?? Array.Empty()) + .DistinctBy(childTag => childTag.Name) + .Select(childTag => new ResolvedTag(childTag, this, getChildren, ns + tag.Name + "/")) + .ToDictionary(childTag => childTag.Name); + RecursiveNamespacedChildren = Children.Values + .SelectMany(childTag => childTag.RecursiveNamespacedChildren.Values.Prepend(childTag)) + .ToDictionary(childTag => childTag.FullName[(ns.Length + Name.Length)..]); + } +} \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 839f902a..61e40c38 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -166,93 +166,140 @@ public void Clear() } #endregion - #region Tags And Genres + #region Tags, Genres, And Content Ratings - private async Task GetTagsForSeries(string seriesId) - { - return (await APIClient.GetSeriesTags(seriesId, GetTagFilter()).ConfigureAwait(false)) - .Where(KeepTag) - .Select(SelectTagName) - .ToArray(); - } - - /// - /// Get the tag filter - /// - /// - private static ulong GetTagFilter() - { - var config = Plugin.Instance.Configuration; - ulong filter = 132L; // We exclude genres and source by default - - if (config.HideAniDbTags) filter |= 1 << 0; - if (config.HideArtStyleTags) filter |= 1 << 1; - if (config.HideMiscTags) filter |= 1 << 3; - if (config.HideSettingTags) filter |= 1 << 5; - if (config.HideProgrammingTags) filter |= 1 << 6; + public Task> GetNamespacedTagsForSeries(string seriesId) + => DataCache.GetOrCreateAsync( + $"series-linked-tags:{seriesId}", + async (_) => { + var nextUserTagId = 1; + var hasCustomTags = false; + var rootTags = new List(); + var tagMap = new Dictionary>(); + var tags = (await APIClient.GetSeriesTags(seriesId).ConfigureAwait(false)) + .OrderBy(tag => tag.Source) + .ThenBy(tag => tag.Source == "User" ? tag.Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).Length : 0) + .ToList(); + foreach (var tag in tags) { + if (Plugin.Instance.Configuration.HideUnverifiedTags && tag.IsVerified.HasValue && !tag.IsVerified.Value) + continue; - return filter; - } + switch (tag.Source) { + case "AniDB": { + var parentKey = $"{tag.Source}:{tag.ParentId ?? 0}"; + if (!tag.ParentId.HasValue) { + rootTags.Add(tag); + continue; + } + if (!tagMap.TryGetValue(parentKey, out var list)) + tagMap[parentKey] = list = new(); + // Remove comment on tag name itself. + if (tag.Name.Contains("--")) + tag.Name = tag.Name.Split("--").First().Trim(); + list.Add(tag); + break; + } + case "User": { + if (!hasCustomTags) { + rootTags.Add(new() { + Id = 0, + Name = "custom user tags", + Description = string.Empty, + IsVerified = true, + IsGlobalSpoiler = false, + IsLocalSpoiler = false, + LastUpdated = DateTime.UnixEpoch, + Source = "Shokofin", + }); + hasCustomTags = true; + } + var parentNames = tag.Name.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries).ToList(); + tag.Name = parentNames.Last(); + parentNames.RemoveAt(parentNames.Count - 1); + var customTagsRoot = rootTags.First(tag => tag.Source == "Shokofin" && tag.Id == 0); + var lastParentTag = customTagsRoot; + while (parentNames.Count > 0) { + // Take the first element from the list. + if (!parentNames.TryRemoveAt(0, out var name)) + break; + + // Make sure the parent's children exists in our map. + var parentKey = $"Shokofin:{lastParentTag.Id}"; + if (!tagMap!.TryGetValue(parentKey, out var children)) + tagMap[parentKey] = children = new(); + + // Add the child tag to the parent's children if needed. + var childTag = children.Find(t => string.Equals(name, t.Name, StringComparison.InvariantCultureIgnoreCase)); + if (childTag is null) + children.Add(childTag = new() { + Id = nextUserTagId++, + ParentId = lastParentTag.Id, + Name = name.ToLowerInvariant(), + IsVerified = true, + Description = string.Empty, + IsGlobalSpoiler = false, + IsLocalSpoiler = false, + LastUpdated = customTagsRoot.LastUpdated, + Source = "Shokofin", + }); + + // Switch to the child tag for the next parent name. + lastParentTag = childTag; + }; + + // Same as above, but for the last parent, be it the root or any other layer. + var lastParentKey = $"Shokofin:{lastParentTag.Id}"; + if (!tagMap!.TryGetValue(lastParentKey, out var lastChildren)) + tagMap[lastParentKey] = lastChildren = new(); + + if (!lastChildren.Any(childTag => string.Equals(childTag.Name, tag.Name, StringComparison.InvariantCultureIgnoreCase))) + lastChildren.Add(new() { + Id = nextUserTagId++, + ParentId = lastParentTag.Id, + Name = tag.Name, + Description = tag.Description, + IsVerified = tag.IsVerified, + IsGlobalSpoiler = tag.IsGlobalSpoiler, + IsLocalSpoiler = tag.IsLocalSpoiler, + Weight = tag.Weight, + LastUpdated = tag.LastUpdated, + Source = "Shokofin", + }); + break; + } + } + } + List? getChildren(string source, int id) => tagMap.TryGetValue($"{source}:{id}", out var list) ? list : null; + return rootTags + .Select(tag => new ResolvedTag(tag, null, getChildren)) + .SelectMany(tag => tag.RecursiveNamespacedChildren.Values.Prepend(tag)) + .OrderBy(tag => tag.FullName) + .ToDictionary(childTag => childTag.FullName) as IReadOnlyDictionary; + } + ); - public async Task GetGenresForSeries(string seriesId) + private async Task GetTagsForSeries(string seriesId) { - // The following magic number is the filter value to allow only genres in the returned list. - var genreSet = (await APIClient.GetSeriesTags(seriesId, 2147483776).ConfigureAwait(false)) - .Select(SelectTagName) - .ToHashSet(); - var sourceGenre = await GetSourceGenre(seriesId).ConfigureAwait(false); - genreSet.Add(sourceGenre); - return genreSet.ToArray(); + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.FilterTags(tags); } - private async Task GetSourceGenre(string seriesId) + private async Task GetGenresForSeries(string seriesId) { - // The following magic number is the filter value to allow only the source type in the returned list. - return(await APIClient.GetSeriesTags(seriesId, 2147483652).ConfigureAwait(false))?.FirstOrDefault()?.Name?.ToLowerInvariant() switch { - "american derived" => "Adapted From Western Media", - "cartoon" => "Adapted From Western Media", - "comic book" => "Adapted From Western Media", - "4-koma" => "Adapted From A Manga", - "manga" => "Adapted From A Manga", - "4-koma manga" => "Adapted From A Manga", - "manhua" => "Adapted From A Manhua", - "manhwa" => "Adapted from a Manhwa", - "movie" => "Adapted From A Movie", - "novel" => "Adapted From A Light/Web Novel", - "rpg" => "Adapted From A Video Game", - "action game" => "Adapted From A Video Game", - "game" => "Adapted From A Video Game", - "erotic game" => "Adapted From An Eroge", - "korean drama" => "Adapted From A Korean Drama", - "television programme" => "Adapted From A Live-Action Show", - "visual novel" => "Adapted From A Visual Novel", - "fan-made" => "Fan-Made", - "remake" => "Remake", - "radio programme" => "Radio Programme", - "biographical film" => "Original Work", - "original work" => "Original Work", - "new" => "Original Work", - "ultra jump" => "Original Work", - _ => "Original Work", - }; + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.FilterGenres(tags); } - private bool KeepTag(Tag tag) + private async Task GetProductionLocations(string seriesId) { - // Filter out unverified tags. - if (Plugin.Instance.Configuration.HideUnverifiedTags && tag.IsVerified.HasValue && !tag.IsVerified.Value) - return false; - - // Filter out any and all spoiler tags. - if (Plugin.Instance.Configuration.HidePlotTags && (tag.IsLocalSpoiler ?? tag.IsSpoiler)) - return false; - - return true; + var tags = await GetNamespacedTagsForSeries(seriesId); + return TagFilter.GetProductionCountriesFromTags(tags); } - private string SelectTagName(Tag tag) + private async Task GetAssumedContentRating(string seriesId) { - return System.Globalization.CultureInfo.InvariantCulture.TextInfo.ToTitleCase(tag.Name); + var tags = await GetNamespacedTagsForSeries(seriesId); + return ContentRating.GetTagBasedContentRating(tags); } #endregion @@ -621,6 +668,7 @@ private async Task CreateSeasonInfo(Series series) Logger.LogTrace("Creating info object for season {SeriesName}. (Series={SeriesId},ExtraSeries={ExtraIds})", series.Name, seriesId, extraIds); + var contentRating = await GetAssumedContentRating(seriesId).ConfigureAwait(false); var (earliestImportedAt, lastImportedAt) = await GetEarliestImportedAtForSeries(seriesId).ConfigureAwait(false); var episodes = (await Task.WhenAll( extraIds.Prepend(seriesId) @@ -639,6 +687,7 @@ private async Task CreateSeasonInfo(Series series) var relationsTasks = detailsIds.Select(id => APIClient.GetSeriesRelations(id)); var genresTasks = detailsIds.Select(id => GetGenresForSeries(id)); var tagsTasks = detailsIds.Select(id => GetTagsForSeries(id)); + var productionLocationsTasks = detailsIds.Select(id => GetProductionLocations(id)); // Await the tasks in order. var cast = (await Task.WhenAll(castTasks)) @@ -659,15 +708,21 @@ private async Task CreateSeasonInfo(Series series) .OrderBy(t => t) .Distinct() .ToArray(); + var productionLocations = (await Task.WhenAll(genresTasks)) + .SelectMany(g => g) + .OrderBy(g => g) + .Distinct() + .ToArray(); // Create the season info using the merged details. - seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); + seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); } else { var cast = await APIClient.GetSeriesCast(seriesId).ConfigureAwait(false); var relations = await APIClient.GetSeriesRelations(seriesId).ConfigureAwait(false); var genres = await GetGenresForSeries(seriesId).ConfigureAwait(false); var tags = await GetTagsForSeries(seriesId).ConfigureAwait(false); - seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags); + var productionLocations = await GetProductionLocations(seriesId).ConfigureAwait(false); + seasonInfo = new SeasonInfo(series, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, contentRating); } foreach (var episode in episodes) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index d7a66d31..5761062b 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -12,6 +12,9 @@ using OrderType = Shokofin.Utils.Ordering.OrderType; using ProviderName = Shokofin.Events.Interfaces.ProviderName; using SpecialOrderType = Shokofin.Utils.Ordering.SpecialOrderType; +using TagIncludeFilter = Shokofin.Utils.TagFilter.TagIncludeFilter; +using TagSource = Shokofin.Utils.TagFilter.TagSource; +using TagWeight = Shokofin.Utils.TagFilter.TagWeight; using TitleProvider = Shokofin.Utils.Text.TitleProvider; namespace Shokofin.Configuration; @@ -168,20 +171,85 @@ public virtual string PrettyUrl #region Tags - public bool HideArtStyleTags { get; set; } + /// + /// Determines if we use the overridden settings for how the tags are set for entries. + /// + public bool TagOverride { get; set; } + + /// + /// All tag sources to use for tags. + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagSource TagSources { get; set; } - public bool HideMiscTags { get; set; } + /// + /// Filter to include tags as tags based on specific criteria. + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagIncludeFilter TagIncludeFilters { get; set; } + + /// + /// Minimum weight of tags to be included, except weightless tags, which has their own filtering through . + /// + public TagWeight TagMinimumWeight { get; set; } - public bool HidePlotTags { get; set; } + /// + /// Determines if we use the overridden settings for how the genres are set for entries. + /// + public bool GenreOverride { get; set; } - public bool HideAniDbTags { get; set; } + /// + /// All tag sources to use for genres. + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagSource GenreSources { get; set; } - public bool HideSettingTags { get; set; } + /// + /// Filter to include tags as genres based on specific criteria. + /// + [JsonConverter(typeof(JsonStringEnumConverter))] + public TagIncludeFilter GenreIncludeFilters { get; set; } - public bool HideProgrammingTags { get; set; } + /// + /// Minimum weight of tags to be included, except weightless tags, which has their own filtering through . + /// + public TagWeight GenreMinimumWeight { get; set; } + /// + /// Hide tags that are not verified by the AniDB moderators yet. + /// public bool HideUnverifiedTags { get; set; } + /// + /// Determines if we use the overridden settings for how the content/official ratings are set for entries. + /// + public bool ContentRatingOverride { get; set; } + + /// + /// Enabled content rating providers. + /// + public ProviderName[] ContentRatingList { get; set; } + + /// + /// The order to go through the content rating providers to retrieve a content rating. + /// + public ProviderName[] ContentRatingOrder { get; set; } + + /// + /// Determines if we use the overridden settings for how the production locations are set for entries. + /// + public bool ProductionLocationOverride { get; set; } + + /// + /// Enabled production location providers. + /// + public ProviderName[] ProductionLocationList { get; set; } + + /// + /// The order to go through the production location providers to retrieve a production location. + /// + public ProviderName[] ProductionLocationOrder { get; set; } + #endregion #region User @@ -278,7 +346,7 @@ public virtual string PrettyUrl /// /// Enable/disable the filtering for new media-folders/libraries. /// - [XmlElement("LibraryFiltering")] + [XmlElement("LibraryFiltering")] public LibraryFilteringMode LibraryFilteringMode { get; set; } /// @@ -362,13 +430,26 @@ public PluginConfiguration() PublicUrl = string.Empty; Username = "Default"; ApiKey = string.Empty; - HideArtStyleTags = false; - HideMiscTags = false; - HidePlotTags = true; - HideAniDbTags = true; - HideSettingTags = false; - HideProgrammingTags = true; + TagOverride = false; + TagSources = TagSource.Elements | TagSource.Themes | TagSource.Fetishes | TagSource.SettingPlace | TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.CustomTags; + TagIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Weightless; + TagMinimumWeight = TagWeight.Weightless; + GenreSources = TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.ContentIndicators | TagSource.Elements; + GenreIncludeFilters = TagIncludeFilter.Child | TagIncludeFilter.Weightless; + GenreMinimumWeight = TagWeight.Three; HideUnverifiedTags = true; + ContentRatingOverride = false; + ContentRatingList = new[] { + ProviderName.AniDB, + ProviderName.TMDB, + }; + ContentRatingOrder = ContentRatingList.ToArray(); + ProductionLocationOverride = false; + ProductionLocationList = new[] { + ProviderName.AniDB, + ProviderName.TMDB, + }; + ProductionLocationOrder = ProductionLocationList.ToArray(); TitleAddForMultipleEpisodes = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 8fb7ac80..4ea36592 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -48,33 +48,36 @@ function filterReconnectIntervals(value) { return Array.from(filteredSet).sort((a, b) => a - b); } -function adjustSortableListElement(element) { - const btnSortable = element.querySelector(".btnSortable"); - const inner = btnSortable.querySelector(".material-icons"); - - if (element.previousElementSibling) { - btnSortable.title = "Up"; - btnSortable.classList.add("btnSortableMoveUp"); - inner.classList.add("keyboard_arrow_up"); - - btnSortable.classList.remove("btnSortableMoveDown"); - inner.classList.remove("keyboard_arrow_down"); +/** + * + * @param {HTMLElement} element + * @param {number} index + */ +function adjustSortableListElement(element, index) { + const button = element.querySelector(".btnSortable"); + const icon = button.querySelector(".material-icons"); + if (index > 0) { + button.title = "Up"; + button.classList.add("btnSortableMoveUp"); + button.classList.remove("btnSortableMoveDown"); + icon.classList.add("keyboard_arrow_up"); + icon.classList.remove("keyboard_arrow_down"); } else { - btnSortable.title = "Down"; - btnSortable.classList.add("btnSortableMoveDown"); - inner.classList.add("keyboard_arrow_down"); - - btnSortable.classList.remove("btnSortableMoveUp"); - inner.classList.remove("keyboard_arrow_up"); + button.title = "Down"; + button.classList.add("btnSortableMoveDown"); + button.classList.remove("btnSortableMoveUp"); + icon.classList.add("keyboard_arrow_down"); + icon.classList.remove("keyboard_arrow_up"); } } -/** @param {PointerEvent} event */ +/** + * @param {PointerEvent} event + **/ function onSortableContainerClick(event) { - const parentWithClass = (element, className) => { - return (element.parentElement.classList.contains(className)) ? element.parentElement : null; - } + const parentWithClass = (element, className) => + (element.parentElement.classList.contains(className)) ? element.parentElement : null; const btnSortable = parentWithClass(event.target, "btnSortable"); if (btnSortable) { const listItem = parentWithClass(btnSortable, "sortableOption"); @@ -93,10 +96,10 @@ function onSortableContainerClick(event) { prev.parentElement.insertBefore(listItem, prev); } } - + let i = 0; for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) - }; + adjustSortableListElement(option, i++); + } } } @@ -274,15 +277,32 @@ async function defaultSubmit(form) { const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings - ["Main", "Alternate"].forEach((type) => setTitleIntoConfig(form, type, config)); + config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; + ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableList(form, "TitleMainList")); + config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; + ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableList(form, "TitleAlternateList")); config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - setDescriptionSourcesIntoConfig(form, config); + config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; + ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; + config.TagOverride = form.querySelector("#TagOverride").checked; + config.TagSources = retrieveSimpleList(form, "TagSources").join(", "); + config.TagIncludeFilters = retrieveSimpleList(form, "TagIncludeFilters").join(", "); + config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; + config.GenreOverride = form.querySelector("#GenreOverride").checked; + config.GenreSources = retrieveSimpleList(form, "GenreSources").join(", "); + config.GenreIncludeFilters = retrieveSimpleList(form, "GenreIncludeFilters").join(", "); + config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value + config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; + ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableList(form, "ContentRatingList")); + config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; + ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -319,7 +339,7 @@ async function defaultSubmit(form) { config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; config.SignalR_AutoReconnectInSeconds = reconnectIntervals; form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); - setSignalREventSourcesIntoConfig(form, config); + config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; if (mediaFolderConfig) { @@ -331,15 +351,6 @@ async function defaultSubmit(form) { config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; } - // Tag settings - config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; - config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; - config.HideMiscTags = form.querySelector("#HideMiscTags").checked; - config.HidePlotTags = form.querySelector("#HidePlotTags").checked; - config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; - config.HideSettingTags = form.querySelector("#HideSettingTags").checked; - config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; - // Advanced settings config.PublicUrl = publicUrl; config.IgnoredFolders = ignoredFolders; @@ -470,15 +481,32 @@ async function syncSettings(form) { const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings - ["Main", "Alternate"].forEach((type) => { setTitleIntoConfig(form, type, config) }); + config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; + ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableList(form, "TitleMainList")); + config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; + ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableList(form, "TitleAlternateList")); config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.TitleAddForMultipleEpisodes = form.querySelector("#TitleAddForMultipleEpisodes").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - setDescriptionSourcesIntoConfig(form, config); + config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; + ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); config.SynopsisCleanLinks = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; + config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; + config.TagOverride = form.querySelector("#TagOverride").checked; + config.TagSources = retrieveSimpleList(form, "TagSources").join(", "); + config.TagIncludeFilters = retrieveSimpleList(form, "TagIncludeFilters").join(", "); + config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; + config.GenreOverride = form.querySelector("#GenreOverride").checked; + config.GenreSources = retrieveSimpleList(form, "GenreSources").join(", "); + config.GenreIncludeFilters = retrieveSimpleList(form, "GenreIncludeFilters").join(", "); + config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value + config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; + ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableList(form, "ContentRatingList")); + config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; + ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); // Provider settings config.AddAniDBId = form.querySelector("#AddAniDBId").checked; @@ -497,15 +525,6 @@ async function syncSettings(form) { config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; - // Tag settings - config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; - config.HideArtStyleTags = form.querySelector("#HideArtStyleTags").checked; - config.HideMiscTags = form.querySelector("#HideMiscTags").checked; - config.HidePlotTags = form.querySelector("#HidePlotTags").checked; - config.HideAniDbTags = form.querySelector("#HideAniDbTags").checked; - config.HideSettingTags = form.querySelector("#HideSettingTags").checked; - config.HideProgrammingTags = form.querySelector("#HideProgrammingTags").checked; - // Advanced settings config.PublicUrl = publicUrl; config.IgnoredFolders = ignoredFolders; @@ -565,7 +584,7 @@ async function syncSignalrSettings(form) { config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; config.SignalR_AutoReconnectInSeconds = reconnectIntervals; - setSignalREventSourcesIntoConfig(form, config); + config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; @@ -629,6 +648,7 @@ async function syncUserSettings(form) { } export default function (page) { + /** @type {HTMLFormElement} */ const form = page.querySelector("#ShokoConfigForm"); const userSelector = form.querySelector("#UserSelector"); const mediaFolderSelector = form.querySelector("#MediaFolderSelector"); @@ -668,7 +688,6 @@ export default function (page) { form.querySelector("#SignalRSection1").removeAttribute("hidden"); form.querySelector("#SignalRSection2").removeAttribute("hidden"); form.querySelector("#UserSection").removeAttribute("hidden"); - form.querySelector("#TagSection").removeAttribute("hidden"); form.querySelector("#AdvancedSection").removeAttribute("hidden"); form.querySelector("#ExperimentalSection").removeAttribute("hidden"); } @@ -685,7 +704,6 @@ export default function (page) { form.querySelector("#SignalRSection1").setAttribute("hidden", ""); form.querySelector("#SignalRSection2").setAttribute("hidden", ""); form.querySelector("#UserSection").setAttribute("hidden", ""); - form.querySelector("#TagSection").setAttribute("hidden", ""); form.querySelector("#AdvancedSection").setAttribute("hidden", ""); form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); } @@ -754,22 +772,65 @@ export default function (page) { } }); - Array.prototype.forEach.call( - form.querySelectorAll("#descriptionSourceList, #TitleMainList, #TitleAlternateList"), - (el) => el.addEventListener("click", onSortableContainerClick) - ); + form.querySelector("#TitleMainList").addEventListener("click", onSortableContainerClick); + form.querySelector("#TitleAlternateList").addEventListener("click", onSortableContainerClick); + form.querySelector("#DescriptionSourceList").addEventListener("click", onSortableContainerClick); + form.querySelector("#ContentRatingList").addEventListener("click", onSortableContainerClick); + form.querySelector("#ProductionLocationList").addEventListener("click", onSortableContainerClick); + + form.querySelector("#TitleMainOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleMainList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); - ["Main", "Alternate"].forEach((type) => { - const settingsList = form.querySelector(`#Title${type}List`); + form.querySelector("#TitleAlternateOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleAlternateList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); - form.querySelector(`#Title${type}Override`).addEventListener("change", ({ target: { checked } }) => { - checked ? settingsList.removeAttribute("hidden") : settingsList.setAttribute("hidden", ""); - }); + form.querySelector("#DescriptionSourceOverride").addEventListener("change", function () { + const list = form.querySelector("#DescriptionSourceList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); }); - form.querySelector("#DescriptionSourceOverride").addEventListener("change", ({ target: { checked } }) => { - const root = form.querySelector("#descriptionSourceList"); - checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); + form.querySelector("#TagOverride").addEventListener("change", function () { + if (this.checked) { + form.querySelector("#TagSources").removeAttribute("hidden"); + form.querySelector("#TagIncludeFilters").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#TagSources").setAttribute("hidden", ""); + form.querySelector("#TagIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").disabled = true; + } + }); + + form.querySelector("#GenreOverride").addEventListener("change", function () { + if (this.checked) { + form.querySelector("#GenreSources").removeAttribute("hidden"); + form.querySelector("#GenreIncludeFilters").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#GenreSources").setAttribute("hidden", ""); + form.querySelector("#GenreIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").disabled = true; + } + }); + + form.querySelector("#ContentRatingOverride").addEventListener("change", function () { + const list = form.querySelector("#ContentRatingList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#ProductionLocationOverride").addEventListener("change", function () { + const list = form.querySelector("#ProductionLocationList"); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); }); page.addEventListener("viewshow", async function () { @@ -785,31 +846,104 @@ export default function (page) { form.querySelector("#Password").value = ""; // Metadata settings - ["Main", "Alternate"].forEach((t) => { setTitleFromConfig(form, t, config) }); + if (form.querySelector("#TitleMainOverride").checked = config.TitleMainOverride) { + form.querySelector("#TitleMainList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleMainList").setAttribute("hidden", ""); + } + initSortableList(form, "TitleMainList", config.TitleMainList, config.TitleMainOrder); + if (form.querySelector("#TitleAlternateOverride").checked = config.TitleAlternateOverride) { + form.querySelector("#TitleAlternateList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleAlternateList").setAttribute("hidden", ""); + } + initSortableList(form, "TitleAlternateList", config.TitleAlternateList, config.TitleAlternateOrder); form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; - form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null ? config.TitleAddForMultipleEpisodes : true; + form.querySelector("#TitleAddForMultipleEpisodes").checked = config.TitleAddForMultipleEpisodes != null + ? config.TitleAddForMultipleEpisodes : true; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - setDescriptionSourcesFromConfig(form, config); - form.querySelector("#CleanupAniDBDescriptions").checked = config.SynopsisCleanMultiEmptyLines || config.SynopsisCleanLinks || config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines; + if (form.querySelector("#DescriptionSourceOverride").checked = config.DescriptionSourceOverride) { + form.querySelector("#DescriptionSourceList").removeAttribute("hidden"); + } + else { + form.querySelector("#DescriptionSourceList").setAttribute("hidden", ""); + } + initSortableList(form, "DescriptionSourceList", config.DescriptionSourceList, config.DescriptionSourceOrder); + form.querySelector("#CleanupAniDBDescriptions").checked = ( + config.SynopsisCleanMultiEmptyLines || + config.SynopsisCleanLinks || + config.SynopsisRemoveSummary || + config.SynopsisCleanMiscLines + ); + form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; + if (form.querySelector("#TagOverride").checked = config.TagOverride) { + form.querySelector("#TagSources").removeAttribute("hidden"); + form.querySelector("#TagIncludeFilters").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#TagMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#TagSources").setAttribute("hidden", ""); + form.querySelector("#TagIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#TagMinimumWeightContainer").disabled = true; + } + initSimpleList(form, "TagSources", config.TagSources.split(",").map(s => s.trim()).filter(s => s)); + initSimpleList(form, "TagIncludeFilters", config.TagIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#TagMinimumWeight").value = config.TagMinimumWeight; + if (form.querySelector("#GenreOverride").checked = config.GenreOverride) { + form.querySelector("#GenreSources").removeAttribute("hidden"); + form.querySelector("#GenreIncludeFilters").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").removeAttribute("hidden"); + form.querySelector("#GenreMinimumWeightContainer").disabled = false; + } + else { + form.querySelector("#GenreSources").setAttribute("hidden", ""); + form.querySelector("#GenreIncludeFilters").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMinimumWeightContainer").disabled = true; + } + initSimpleList(form, "GenreSources", config.GenreSources.split(",").map(s => s.trim()).filter(s => s)); + initSimpleList(form, "GenreIncludeFilters", config.GenreIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#GenreMinimumWeight").value = config.GenreMinimumWeight; + if (form.querySelector("#ContentRatingOverride").checked = config.ContentRatingOverride) { + form.querySelector("#ContentRatingList").removeAttribute("hidden"); + } + else { + form.querySelector("#ContentRatingList").setAttribute("hidden", ""); + } + initSortableList(form, "ContentRatingList", config.ContentRatingList, config.ContentRatingOrder); + if (form.querySelector("#ProductionLocationOverride").checked = config.ProductionLocationOverride) { + form.querySelector("#ProductionLocationList").removeAttribute("hidden"); + } + else { + form.querySelector("#ProductionLocationList").setAttribute("hidden", ""); + } + initSortableList(form, "ProductionLocationList", config.ProductionLocationList, config.ProductionLocationOrder); // Provider settings form.querySelector("#AddAniDBId").checked = config.AddAniDBId; form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings - form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false; - form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; - form.querySelector("#SeasonOrdering").disabled = !form.querySelector("#UseGroupsForShows").checked; - if (form.querySelector("#UseGroupsForShows").checked) { + if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows || false) { form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + form.querySelector("#SeasonOrdering").disabled = false; } else { form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + form.querySelector("#SeasonOrdering").disabled = true; } + form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; form.querySelector("#CollectionGrouping").value = config.CollectionGrouping || "Default"; - form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo != null ? config.CollectionMinSizeOfTwo : true; - form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null ? config.SeparateMovies : true; - form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; + form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo != null + ? config.CollectionMinSizeOfTwo : true; + form.querySelector("#SeparateMovies").checked = config.SeparateMovies != null + ? config.SeparateMovies : true; + form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" + ? "AfterSeason" : config.SpecialsPlacement; form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes || false; form.querySelector("#AddTrailers").checked = config.AddTrailers || false; form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos || false; @@ -817,37 +951,34 @@ export default function (page) { form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata || false; // Media Folder settings - form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null ? config.VirtualFileSystem : true; + form.querySelector("#VirtualFileSystem").checked = config.VirtualFileSystem != null + ? config.VirtualFileSystem : true; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; - mediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => ``).join(""); + mediaFolderSelector.innerHTML += config.MediaFolders + .map((mediaFolder) => ``) + .join(""); // SignalR settings form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); - setSignalREventSourcesFromConfig(form, config); - signalrMediaFolderSelector.innerHTML += config.MediaFolders.map((mediaFolder) => ``).join(""); + initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); + signalrMediaFolderSelector.innerHTML += config.MediaFolders + .map((mediaFolder) => ``) + .join(""); form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; // User settings userSelector.innerHTML += users.map((user) => ``).join(""); - // Tag settings - form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; - form.querySelector("#HideArtStyleTags").checked = config.HideArtStyleTags; - form.querySelector("#HideMiscTags").checked = config.HideMiscTags; - form.querySelector("#HidePlotTags").checked = config.HidePlotTags; - form.querySelector("#HideAniDbTags").checked = config.HideAniDbTags; - form.querySelector("#HideSettingTags").checked = config.HideSettingTags; - form.querySelector("#HideProgrammingTags").checked = config.HideProgrammingTags; - // Advanced settings form.querySelector("#PublicUrl").value = config.PublicUrl; form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); // Experimental settings form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; - form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies != null ? config.EXPERIMENTAL_SplitThenMergeMovies : true; + form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked = config.EXPERIMENTAL_SplitThenMergeMovies != null + ? config.EXPERIMENTAL_SplitThenMergeMovies : true; form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; @@ -919,128 +1050,86 @@ export default function (page) { }); } -function setDescriptionSourcesIntoConfig(form, config) { - const override = form.querySelector("#DescriptionSourceOverride"); - const descriptionElements = form.querySelectorAll(`#descriptionSourceList .chkDescriptionSource`); - config.DescriptionSourceList = Array.prototype.filter.call(descriptionElements, - (el) => el.checked) - .map((el) => el.dataset.descriptionSource); - - config.DescriptionSourceOrder = Array.prototype.map.call(descriptionElements, - (el) => el.dataset.descriptionSource - ); - - config.DescriptionSourceOverride = override.checked; -} - -function setDescriptionSourcesFromConfig(form, config) { - const root = form.querySelector("#descriptionSourceList"); - const override = form.querySelector("#DescriptionSourceOverride"); - const list = root.querySelector(".checkboxList"); - const listItems = list.querySelectorAll('.listItem'); - - override.checked = config.DescriptionSourceOverride; - override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); - - for (const item of listItems) { - const source = item.dataset.descriptionSource; - if (config.DescriptionSourceList.includes(source)) { - item.querySelector(".chkDescriptionSource").checked = true; - } - if (config.DescriptionSourceOrder.includes(source)) { - list.removeChild(item); // This is safe to be removed as we can re-add it in the next loop - } - } - - for (const source of config.DescriptionSourceOrder) { - const targetElement = Array.prototype.find.call(listItems, (el) => el.dataset.descriptionSource === source); - if (targetElement) { - list.append(targetElement); - } - } - - for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) - }; -} - -/** - * This function **must** be called for each type of title separately, lest the config be incomplete. - * @param {"Main"|"Alternate"} type - */ -function setTitleIntoConfig(form, type, config) { - const titleElements = form.querySelectorAll(`#Title${type}List .chkTitleSource`); - const getSettingName = (el) => `${el.dataset.titleProvider}_${el.dataset.titleStyle}`; - - config[`Title${type}List`] = Array.prototype.filter.call(titleElements, - (el) => el.checked) - .map((el) => getSettingName(el)); - - config[`Title${type}Order`] = Array.prototype.map.call(titleElements, - (el) => getSettingName(el) - ); - - config[`Title${type}Override`] = form.querySelector(`#Title${type}Override`).checked -} - -/** - * This function **must** be called for each type of title separately, lest the config be incomplete. - * @param {"Main"|"Alternate"} type +/** + * Initialize a selectable list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @param {string[]} order + * @returns {void} */ -function setTitleFromConfig(form, type, config) { - const root = form.querySelector(`#Title${type}List`); - const override = form.querySelector(`#Title${type}Override`); - const list = root.querySelector(`.checkboxList`); - const listItems = list.querySelectorAll('.listItem'); - - override.checked = config[`Title${type}Override`]; - override.checked ? root.removeAttribute("hidden") : root.setAttribute("hidden", ""); - - const getSettingName = (el) => `${el.dataset.titleProvider}_${el.dataset.titleStyle}`; - - for (const item of listItems) { - const setting = getSettingName(item); - if (config[`Title${type}List`].includes(setting)) - item.querySelector(".chkTitleSource").checked = true; - if (config[`Title${type}Order`].includes(setting)) - list.removeChild(item); - } - - for (const setting of config[`Title${type}Order`]) { - const targetElement = Array.prototype.find.call( - listItems, - (el) => getSettingName(el) === setting - ); - if (targetElement) - list.append(targetElement); +function initSortableList(form, name, enabled, order) { + let index = 0; + const list = form.querySelector(`#${name} .checkboxList`); + const listItems = Array.from(list.querySelectorAll(".listItem")) + .map((item) => ({ + item, + checkbox: item.querySelector("input[data-option]"), + isSortable: item.className.includes("sortableOption"), + })) + .map(({ item, checkbox, isSortable }) => ({ + item, + checkbox, + isSortable, + option: checkbox.dataset.option, + })); + list.innerHTML = ""; + for (const option of order) { + const { item, checkbox, isSortable } = listItems.find((item) => item.option === option) || {}; + if (!item) + continue; + list.append(item); + if (enabled.includes(option)) + checkbox.checked = true; + if (isSortable) + adjustSortableListElement(item, index++); } +} - for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option) +/** + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @returns {void} + **/ +function initSimpleList(form, name, enabled) { + for (const item of Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`))) { + if (enabled.includes(item.dataset.option)) + item.checked = true; } } -/** @param {HTMLFormElement} form */ -function setSignalREventSourcesIntoConfig(form, config) { - /** @type {HTMLInputElement[]} */ - const checkboxList = form.querySelectorAll(`#SignalREventSources .listItem input[data-signalr-event-source]`); - const resultArr = []; - for (const item of checkboxList) { - if (item.checked) { - resultArr.push(item.dataset.signalrEventSource); - } - } - config.SignalR_EventSources = resultArr; +/** + * Retrieve the enabled state and order list from a sortable list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @returns {[boolean, string[], string[]]} + */ +function retrieveSortableList(form, name) { + const titleElements = Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`)); + const getValue = (el) => el.dataset.option; + return [ + titleElements + .filter((el) => el.checked) + .map(getValue) + .sort(), + titleElements + .map(getValue), + ]; } -/** @param {HTMLFormElement} form */ -function setSignalREventSourcesFromConfig(form, config) { - /** @type {HTMLInputElement[]} */ - const checkboxList = form.querySelectorAll(`#SignalREventSources .listItem input[data-signalr-event-source]`); - for (const item of checkboxList) { - const eventSource = item.dataset.signalrEventSource; - if (config.SignalR_EventSources.includes(eventSource)) { - item.checked = true; - } - } +/** + * Retrieve the enabled state from a simple list. + * + * @param {HTMLFormElement} form + * @param {string} name - Name of the selector list to retrieve. + * @returns {string[]} + **/ +function retrieveSimpleList(form, name) { + return Array.from(form.querySelectorAll(`#${name} .listItem input[data-option]`)) + .filter(item => item.checked) + .map(item => item.dataset.option) + .sort(); } \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 7e58a7f2..a202f30a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -44,6 +44,44 @@

Connection Settings

Metadata Settings

+
+ Customize how the plugin will source the metadata for entries. +
+
+ +
Add the type and number to the title of some episodes.
+
+
+ +
Will add the title and description for every episode in a multi-episode entry.
+
+
+ +
Will add any title in the selected language if no official title is found.
+
+
+ +
Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.
+
+
+ +
Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.
+