diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 217e091..f84eb45 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Linq; using Shokofin.API.Models; +using Shokofin.Configuration; using PersonInfo = MediaBrowser.Controller.Entities.PersonInfo; using PersonKind = Jellyfin.Data.Enums.PersonKind; @@ -118,7 +119,7 @@ public SeasonInfo( string[] genres, string[] tags, string[] productionLocations, - IReadOnlyDictionary> tagSeriesMap, + IReadOnlyDictionary seriesConfigurationMap, string? contentRating) { var seriesId = series.IDs.Shoko.ToString(); @@ -151,18 +152,14 @@ public SeasonInfo( .ThenBy(episode => episode.AniDB.EpisodeNumber) .ToList(); - // Take note of any episodes which should be mapped as specials. - var mappedAsSpecials = episodes - .Where(episode => episode.AniDB.Type is EpisodeType.Normal or EpisodeType.Other && tagSeriesMap[episode.Shoko.IDs.ParentSeries.ToString()].ContainsKey("/custom user tags/shokofin/map as specials")) - .ToHashSet(); - // Iterate over the episodes once and store some values for later use. int index = 0; int lastNormalEpisode = -1; foreach (var episode in episodes) { if (episode.Shoko.IsHidden) continue; - var episodeType = mappedAsSpecials.Contains(episode) ? EpisodeType.Special : episode.AniDB.Type; + var seriesConfiguration = seriesConfigurationMap[episode.Shoko.IDs.ParentSeries.ToString()]; + var episodeType = seriesConfiguration.EpisodesAsSpecials ? EpisodeType.Special : episode.AniDB.Type; switch (episodeType) { case EpisodeType.Normal: episodesList.Add(episode); @@ -178,6 +175,10 @@ public SeasonInfo( if (episode.ExtraType != null) { extrasList.Add(episode); } + else if (episodeType is EpisodeType.Special && seriesConfiguration.SpecialsAsEpisodes) { + episodesList.Add(episode); + lastNormalEpisode = index; + } else if (episodeType is EpisodeType.Special) { specialsList.Add(episode); if (lastNormalEpisode == -1) { @@ -186,7 +187,7 @@ public SeasonInfo( else { var previousEpisode = episodes .GetRange(lastNormalEpisode, index - lastNormalEpisode) - .FirstOrDefault(e => e.AniDB.Type is EpisodeType.Normal && !mappedAsSpecials.Contains(e)); + .FirstOrDefault(e => e.AniDB.Type is EpisodeType.Normal && !seriesConfiguration.EpisodesAsSpecials); if (previousEpisode != null) specialsAnchorDictionary[episode] = previousEpisode; } @@ -242,7 +243,7 @@ public SeasonInfo( else { var previousEpisode = episodes .GetRange(lastNormalEpisode, index - lastNormalEpisode) - .FirstOrDefault(e => specialsList.Contains(e)); + .FirstOrDefault(e => episodesList.Contains(e)); if (previousEpisode != null) specialsAnchorDictionary[episode] = previousEpisode; } diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index ef1a033..e5bbece 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -90,7 +90,7 @@ public enum EpisodeType /// /// A catch-all type for future extensions when a provider can't use a current episode type, but knows what the future type should be. /// - Other = 1, + Other = 2, /// /// The episode type is unknown. @@ -100,7 +100,7 @@ public enum EpisodeType /// /// A normal episode. /// - Normal = 2, + Normal = 1, /// /// A special episode. diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 4823abd..bb55c5e 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -9,6 +9,7 @@ using System.Threading.Tasks; using Shokofin.API.Info; using Shokofin.API.Models; +using Shokofin.Configuration; using Shokofin.ExternalIds; using Shokofin.Utils; @@ -139,6 +140,52 @@ public void Clear() Logger.LogDebug("Cleanup complete."); } + #endregion + #region Series Settings + + public Task GetSeriesConfiguration(string id) + => DataCache.GetOrCreateAsync($"series-settings:{id}", async () => { + var seriesSettings = new SeriesConfiguration() + { + StructureType = Plugin.Instance.Configuration.DefaultLibraryStructure, + MergeOverride = SeriesMergingOverride.None, + EpisodesAsSpecials = false, + SpecialsAsEpisodes = false, + }; + var tags = await GetNamespacedTagsForSeries(id); + if (!tags.TryGetValue("/custom user tags/shokofin", out var customTags)) + return seriesSettings; + + tags = customTags.RecursiveNamespacedChildren; + if (tags.ContainsKey("/anidb structure")) + seriesSettings.StructureType = SeriesStructureType.AniDB_Anime; + else if (tags.ContainsKey("/shoko structure")) + seriesSettings.StructureType = SeriesStructureType.Shoko_Groups; + else if (tags.ContainsKey("/tmdb structure")) + seriesSettings.StructureType = SeriesStructureType.TMDB_SeriesAndMovies; + + if (tags.ContainsKey("/no merge")) + seriesSettings.MergeOverride = SeriesMergingOverride.NoMerge; + else if (tags.ContainsKey("/merge forward")) + seriesSettings.MergeOverride = SeriesMergingOverride.MergeForward; + else if (tags.ContainsKey("/merge backward")) + seriesSettings.MergeOverride = SeriesMergingOverride.MergeBackward; + + if (tags.ContainsKey("/episodes as specials")) { + seriesSettings.EpisodesAsSpecials = true; + } + else if (tags.ContainsKey("/no episodes as specials") || !seriesSettings.EpisodesAsSpecials) { + seriesSettings.EpisodesAsSpecials = false; + + if (tags.ContainsKey("/specials as episodes")) + seriesSettings.SpecialsAsEpisodes = true; + else if (tags.ContainsKey("/no specials as episodes")) + seriesSettings.SpecialsAsEpisodes = false; + } + + return seriesSettings; + }); + #endregion #region Tags, Genres, And Content Ratings @@ -756,7 +803,7 @@ private async Task CreateSeasonInfo(Series series) var genresTasks = detailsIds.Select(id => GetGenresForSeries(id)); var tagsTasks = detailsIds.Select(id => GetTagsForSeries(id)); var productionLocationsTasks = detailsIds.Select(id => GetProductionLocations(id)); - var namespacedTagsTasks = detailsIds.Select(id => GetNamespacedTagsForSeries(id)); + var seriesConfigurationsTasks = detailsIds.Select(id => GetSeriesConfiguration(id)); // Await the tasks in order. var cast = (await Task.WhenAll(castTasks)) @@ -782,22 +829,22 @@ private async Task CreateSeasonInfo(Series series) .OrderBy(g => g) .Distinct() .ToArray(); - var namespacedTags = (await Task.WhenAll(namespacedTagsTasks)) + var seriesConfigurations = (await Task.WhenAll(seriesConfigurationsTasks)) .Select((t, i) => (t, i)) .ToDictionary(t => detailsIds[t.i], (t) => t.t); // Create the season info using the merged details. - seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, namespacedTags, contentRating); + seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, seriesConfigurations, 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); var productionLocations = await GetProductionLocations(seriesId).ConfigureAwait(false); - var namespacedTags = new Dictionary>() { - { seriesId, await GetNamespacedTagsForSeries(seriesId).ConfigureAwait(false) }, + var seriesConfigurations = new Dictionary() { + { seriesId, await GetSeriesConfiguration(seriesId).ConfigureAwait(false) }, }; - seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, namespacedTags, contentRating); + seasonInfo = new SeasonInfo(series, customSeriesType, extraIds, earliestImportedAt, lastImportedAt, episodes, cast, relations, genres, tags, productionLocations, seriesConfigurations, contentRating); } foreach (var episode in episodes) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 2e8c488..4baedb9 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -306,11 +306,42 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// public MergeVersionSortSelector[] MergeVersionSortSelectorOrder { get; set; } + public SeriesStructureType DefaultLibraryStructure + { + get { + if (UseGroupsForShows && !UseTmdbForShows) + return SeriesStructureType.Shoko_Groups; + if (UseTmdbForShows && !UseGroupsForShows) + return SeriesStructureType.TMDB_SeriesAndMovies; + return SeriesStructureType.AniDB_Anime; + } + set { + switch (value) { + case SeriesStructureType.Shoko_Groups: + UseGroupsForShows = true; + UseTmdbForShows = false; + break; + case SeriesStructureType.TMDB_SeriesAndMovies: + UseGroupsForShows = false; + UseTmdbForShows = true; + break; + default: + UseGroupsForShows = false; + UseTmdbForShows = false; + break; + } + } + } + /// /// Use Shoko Groups to group Shoko Series together to create the show entries. /// + [JsonIgnore] public bool UseGroupsForShows { get; set; } + [JsonIgnore] + public bool UseTmdbForShows { get; set; } + /// /// Separate movies out of show type libraries. /// @@ -634,6 +665,7 @@ public PluginConfiguration() MergeVersionSortSelector.NoVariation, ]; UseGroupsForShows = false; + UseTmdbForShows = false; SeparateMovies = false; FilterMovieLibraries = true; MovieSpecialsAsExtraFeaturettes = false; diff --git a/Shokofin/Configuration/SeriesConfiguration.cs b/Shokofin/Configuration/SeriesConfiguration.cs new file mode 100644 index 0000000..bbe61a8 --- /dev/null +++ b/Shokofin/Configuration/SeriesConfiguration.cs @@ -0,0 +1,13 @@ + +namespace Shokofin.Configuration; + +public class SeriesConfiguration +{ + public SeriesStructureType StructureType { get; set; } + + public SeriesMergingOverride MergeOverride { get; set; } + + public bool EpisodesAsSpecials { get; set; } + + public bool SpecialsAsEpisodes { get; set; } +} diff --git a/Shokofin/Configuration/SeriesMergingOverride.cs b/Shokofin/Configuration/SeriesMergingOverride.cs new file mode 100644 index 0000000..ab2c29e --- /dev/null +++ b/Shokofin/Configuration/SeriesMergingOverride.cs @@ -0,0 +1,9 @@ + +namespace Shokofin.Configuration; + +public enum SeriesMergingOverride { + None = 0, + NoMerge = 1, + MergeForward = 2, + MergeBackward = 3, +} \ No newline at end of file diff --git a/Shokofin/Configuration/SeriesStructureType.cs b/Shokofin/Configuration/SeriesStructureType.cs new file mode 100644 index 0000000..c933289 --- /dev/null +++ b/Shokofin/Configuration/SeriesStructureType.cs @@ -0,0 +1,20 @@ + +namespace Shokofin.Configuration; + +/// +/// Library structure type to use for series. +/// +public enum SeriesStructureType { + /// + /// Structure the libraries as AniDB anime. + /// + AniDB_Anime, + /// + /// Structure the libraries using Shoko's group structure. + /// + Shoko_Groups, + /// + /// Structure the libraries as TMDB series and/or movies. + /// + TMDB_SeriesAndMovies, +} \ No newline at end of file diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index 3db89ea..9d2abab 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -132,9 +132,9 @@ createControllerFactory({ } }); - form.querySelector("#UseGroupsForShows").addEventListener("change", function () { - form.querySelector("#SeasonOrdering").disabled = !this.checked; - if (this.checked) { + form.querySelector("#DefaultLibraryStructure").addEventListener("change", function () { + form.querySelector("#SeasonOrdering").disabled = this.value === "Shoko_Groups"; + if (this.value === "Shoko_Groups") { form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); } else { @@ -407,7 +407,7 @@ function applyFormToConfig(form, config) { const libraryId = form.querySelector("#MediaFolderSelector").value.split(","); const mediaFolders = libraryId ? config.MediaFolders.filter((m) => m.LibraryId === libraryId) : undefined; - config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; + config.DefaultLibraryStructure = form.querySelector("#DefaultLibraryStructure").value; config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; config.FilterMovieLibraries = !form.querySelector("#DisableFilterMovieLibraries").checked; @@ -568,7 +568,7 @@ async function applyConfigToForm(form, config) { return acc; }, []); - if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows) { + if ((form.querySelector("#DefaultLibraryStructure").value = config.DefaultLibraryStructure) === "Shoko_Groups") { form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); form.querySelector("#SeasonOrdering").disabled = false; } diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 9cc24c2..3121bcc 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -1115,12 +1115,14 @@

TvDB | Shows, Episodes

Basic Settings

-
- -
+
+ + +
This will use Shoko's group structure to construct the shows in the libraries instead of the flat AniDB-esque structure you would otherwise see.