Skip to content

Commit

Permalink
feat: add series configuration + more
Browse files Browse the repository at this point in the history
- Added new per-series configuration, stored on the server as custom tags.

- Added a new selector to chose between anidb structure, shoko structure, and tmdb structure to use by default for all libraries, and together with the new per-series configuration we allow it to be overridden on a per-series  basis, though no UI to do it yet.

- Added a new per-series setting to display specials as normal episodes after the normal episodes.
  • Loading branch information
revam committed Nov 15, 2024
1 parent a8ce541 commit 90454dc
Show file tree
Hide file tree
Showing 9 changed files with 152 additions and 28 deletions.
19 changes: 10 additions & 9 deletions Shokofin/API/Info/SeasonInfo.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -118,7 +119,7 @@ public SeasonInfo(
string[] genres,
string[] tags,
string[] productionLocations,
IReadOnlyDictionary<string, IReadOnlyDictionary<string, ResolvedTag>> tagSeriesMap,
IReadOnlyDictionary<string, SeriesConfiguration> seriesConfigurationMap,
string? contentRating)
{
var seriesId = series.IDs.Shoko.ToString();
Expand Down Expand Up @@ -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);
Expand All @@ -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) {
Expand All @@ -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;
}
Expand Down Expand Up @@ -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;
}
Expand Down
4 changes: 2 additions & 2 deletions Shokofin/API/Models/Episode.cs
Original file line number Diff line number Diff line change
Expand Up @@ -90,7 +90,7 @@ public enum EpisodeType
/// <summary>
/// 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.
/// </summary>
Other = 1,
Other = 2,

/// <summary>
/// The episode type is unknown.
Expand All @@ -100,7 +100,7 @@ public enum EpisodeType
/// <summary>
/// A normal episode.
/// </summary>
Normal = 2,
Normal = 1,

/// <summary>
/// A special episode.
Expand Down
59 changes: 53 additions & 6 deletions Shokofin/API/ShokoAPIManager.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -139,6 +140,52 @@ public void Clear()
Logger.LogDebug("Cleanup complete.");
}

#endregion
#region Series Settings

public Task<SeriesConfiguration> 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

Expand Down Expand Up @@ -756,7 +803,7 @@ private async Task<SeasonInfo> 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))
Expand All @@ -782,22 +829,22 @@ private async Task<SeasonInfo> 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<string, IReadOnlyDictionary<string, ResolvedTag>>() {
{ seriesId, await GetNamespacedTagsForSeries(seriesId).ConfigureAwait(false) },
var seriesConfigurations = new Dictionary<string, SeriesConfiguration>() {
{ 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)
Expand Down
32 changes: 32 additions & 0 deletions Shokofin/Configuration/PluginConfiguration.cs
Original file line number Diff line number Diff line change
Expand Up @@ -306,11 +306,42 @@ public DescriptionProvider[] ThirdPartyIdProviderList
/// </summary>
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;
}
}
}

/// <summary>
/// Use Shoko Groups to group Shoko Series together to create the show entries.
/// </summary>
[JsonIgnore]
public bool UseGroupsForShows { get; set; }

[JsonIgnore]
public bool UseTmdbForShows { get; set; }

/// <summary>
/// Separate movies out of show type libraries.
/// </summary>
Expand Down Expand Up @@ -634,6 +665,7 @@ public PluginConfiguration()
MergeVersionSortSelector.NoVariation,
];
UseGroupsForShows = false;
UseTmdbForShows = false;
SeparateMovies = false;
FilterMovieLibraries = true;
MovieSpecialsAsExtraFeaturettes = false;
Expand Down
13 changes: 13 additions & 0 deletions Shokofin/Configuration/SeriesConfiguration.cs
Original file line number Diff line number Diff line change
@@ -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; }
}
9 changes: 9 additions & 0 deletions Shokofin/Configuration/SeriesMergingOverride.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@

namespace Shokofin.Configuration;

public enum SeriesMergingOverride {
None = 0,
NoMerge = 1,
MergeForward = 2,
MergeBackward = 3,
}
20 changes: 20 additions & 0 deletions Shokofin/Configuration/SeriesStructureType.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@

namespace Shokofin.Configuration;

/// <summary>
/// Library structure type to use for series.
/// </summary>
public enum SeriesStructureType {
/// <summary>
/// Structure the libraries as AniDB anime.
/// </summary>
AniDB_Anime,
/// <summary>
/// Structure the libraries using Shoko's group structure.
/// </summary>
Shoko_Groups,
/// <summary>
/// Structure the libraries as TMDB series and/or movies.
/// </summary>
TMDB_SeriesAndMovies,
}
10 changes: 5 additions & 5 deletions Shokofin/Pages/Scripts/Settings.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down
14 changes: 8 additions & 6 deletions Shokofin/Pages/Settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -1115,12 +1115,14 @@ <h3 class="listItemBodyText">TvDB | Shows, Episodes</h3>
<legend>
<h3>Basic Settings</h3>
</legend>
<div class="checkboxContainer checkboxContainer-withDescription">
<label class="emby-checkbox-label">
<input is="emby-checkbox" type="checkbox" id="UseGroupsForShows" />
<span>Use Groups for Shows</span>
</label>
<div class="fieldDescription checkboxFieldDescription">
<div class="selectContainer selectContainer-withDescription expert-only">
<label class="selectLabel" for="DefaultLibraryStructure">Library Structure Mode</label>
<select is="emby-select" id="DefaultLibraryStructure" name="DefaultLibraryStructure" class="emby-select-withcolor emby-select">
<option value="AniDB_Anime" selected>AniDB Anime Structure (Default)</option>
<option value="Shoko_Groups">Shoko Group Structure</option>
<option value="TMDB_SeriesAndMovies">TMDB Shows &amp; Movies Structure</option>
</select>
<div class="fieldDescription">
<div>
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.
</div>
Expand Down

0 comments on commit 90454dc

Please sign in to comment.