From 5996ba5b55422ea5891978ad1ab0d2b9f4de15ca Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Wed, 17 Jul 2024 00:01:56 +0200 Subject: [PATCH 001/144] fix: use local var --- Shokofin/Providers/CustomSeriesProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index f9313b94..a20d40a7 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -202,7 +202,7 @@ public async Task FetchAsync(Series series, MetadataRefreshOptio foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) existingEpisodes.Add(episodeId); - foreach (var episodeInfo in seasonInfo.EpisodeList.Concat(seasonInfo.AlternateEpisodesList)) { + foreach (var episodeInfo in episodeList) { var episodeParentIndex = Ordering.GetSeasonNumber(showInfo, seasonInfo, episodeInfo); if (episodeParentIndex != seasonNumber) continue; From 7f362fb4bbd302eba4578db29093e7e4938dd2b5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Wed, 17 Jul 2024 00:02:03 +0200 Subject: [PATCH 002/144] misc: update target abi --- build.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.yaml b/build.yaml index 896ddd40..c9b30e88 100644 --- a/build.yaml +++ b/build.yaml @@ -1,7 +1,7 @@ name: "Shoko" guid: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" imageUrl: https://raw.githubusercontent.com/ShokoAnime/Shokofin/metadata/banner.png -targetAbi: "10.9.0.0" +targetAbi: "10.9.7.0" owner: "ShokoAnime" overview: "Manage your anime from Jellyfin using metadata from Shoko" description: > From 4890504fb9643750ce26d6639d0c908aa0e853fc Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sun, 28 Jul 2024 01:17:30 +0200 Subject: [PATCH 003/144] fix: append base path to image urls - Append the base path to image urls served by the plugin, instead of overwriting the existing base path set on the base url. --- Shokofin/API/Models/Image.cs | 2 +- Shokofin/Web/ImageHostUrl.cs | 21 +++++++++++++++------ 2 files changed, 16 insertions(+), 7 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 5a9f9e2c..6bc33cbe 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -69,7 +69,7 @@ public virtual bool IsAvailable /// /// The image URL public string ToURLString() - => new Uri(new Uri(Web.ImageHostUrl.Value), $"/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); + => new Uri(new Uri(Web.ImageHostUrl.BaseUrl), $"{Web.ImageHostUrl.BasePath}/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); } /// diff --git a/Shokofin/Web/ImageHostUrl.cs b/Shokofin/Web/ImageHostUrl.cs index edd93d7e..71bb453c 100644 --- a/Shokofin/Web/ImageHostUrl.cs +++ b/Shokofin/Web/ImageHostUrl.cs @@ -12,9 +12,14 @@ namespace Shokofin.Web; public class ImageHostUrl : IAsyncActionFilter { /// - /// The current image host url base to use. + /// The current image host base url to use. /// - public static string Value { get; private set; } = "http://localhost:8096/"; + public static string BaseUrl { get; private set; } = "http://localhost:8096/"; + + /// + /// The current image host base path to use. + /// + public static string BasePath { get; private set; } = "/"; private readonly object LockObj = new(); @@ -26,12 +31,16 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE var uriBuilder = new UriBuilder(request.Scheme, request.Host.Host, request.Host.Port ?? (request.Scheme == "https" ? 443 : 80), $"{request.PathBase}{request.Path}", request.QueryString.HasValue ? request.QueryString.Value : null); var result = RemoteImagesRegex.Match(uriBuilder.Path); if (result.Success) { - uriBuilder.Path = result.Length == uriBuilder.Path.Length ? "/" : uriBuilder.Path[..^result.Length] + "/"; + var path = result.Length == uriBuilder.Path.Length ? "" : uriBuilder.Path[..^result.Length]; + uriBuilder.Path = ""; uriBuilder.Query = ""; var uri = uriBuilder.ToString(); - lock (LockObj) - if (!string.Equals(uri, Value)) - Value = uri; + lock (LockObj) { + if (!string.Equals(uri, BaseUrl)) + BaseUrl = uri; + if (!string.Equals(path, BasePath)) + BasePath = path; + } } await next(); } From 359bfef0083346f04f8251c8eb8f0301329d6ac4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal Date: Sun, 28 Jul 2024 20:29:08 +0200 Subject: [PATCH 004/144] fix: fix images with base path - Fix downloading images with the new base path when the image selector/downloader modal haven't been opened yet in any client. --- Shokofin/Web/ImageHostUrl.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Web/ImageHostUrl.cs b/Shokofin/Web/ImageHostUrl.cs index 71bb453c..27cf24f5 100644 --- a/Shokofin/Web/ImageHostUrl.cs +++ b/Shokofin/Web/ImageHostUrl.cs @@ -19,7 +19,7 @@ public class ImageHostUrl : IAsyncActionFilter /// /// The current image host base path to use. /// - public static string BasePath { get; private set; } = "/"; + public static string BasePath { get; private set; } = ""; private readonly object LockObj = new(); From 35bb6ca0863fd9351c15d7fb5fc58de774deaa18 Mon Sep 17 00:00:00 2001 From: Harshith Mohan <26010946+harshithmohan@users.noreply.github.com> Date: Thu, 8 Aug 2024 00:38:33 +0530 Subject: [PATCH 005/144] Fix models for latest server daily --- Shokofin/API/Models/Image.cs | 16 +++++++++++++--- Shokofin/API/Models/Images.cs | 2 +- Shokofin/API/Models/Series.cs | 17 +++++++++++------ Shokofin/Providers/ImageProvider.cs | 2 +- 4 files changed, 26 insertions(+), 11 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 6bc33cbe..b20c9bae 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -19,7 +19,7 @@ public class Image /// The image's id. Usually an int, but in the case of resources /// then it is the resource name. /// - public string ID { get; set; } = string.Empty; + public int ID { get; set; } = 0; /// @@ -123,7 +123,12 @@ public enum ImageType /// /// /// - Fanart = 4, + Backdrop = 4, + + /// + /// + /// + Fanart = Backdrop, /// /// @@ -135,8 +140,13 @@ public enum ImageType /// Staff = 6, + /// + /// Clear-text logo. + /// + Logo = 7, + /// /// Static resources are only valid if the is set to . /// Static = 100 -} \ No newline at end of file +} diff --git a/Shokofin/API/Models/Images.cs b/Shokofin/API/Models/Images.cs index eeda6d2b..894faf56 100644 --- a/Shokofin/API/Models/Images.cs +++ b/Shokofin/API/Models/Images.cs @@ -6,7 +6,7 @@ public class Images { public List Posters { get; set; } = new List(); - public List Fanarts { get; set; } = new List(); + public List Backdrops { get; set; } = new List(); public List Banners { get; set; } = new List(); } diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 179799dd..7288cdcc 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -7,7 +7,7 @@ namespace Shokofin.API.Models; public class Series { public string Name { get; set; } = string.Empty; - + public int Size { get; set; } /// @@ -40,7 +40,7 @@ public class Series /// [JsonPropertyName("TvDB")] public List TvDBEntityList { get; set; }= new(); - + public SeriesSizes Sizes { get; set; } = new(); /// @@ -106,7 +106,7 @@ public class AniDB public int? EpisodeCount { get; set; } /// - /// The average rating for the anime. Only available on + /// The average rating for the anime. Only available on /// public Rating? Rating { get; set; } @@ -135,7 +135,7 @@ public class AniDBWithDate : AniDB public new List Titles { get; set; } = new(); /// <summary> - /// The average rating for the anime. Only available on + /// The average rating for the anime. Only available on /// </summary> public new Rating Rating { get; set; } = new(); @@ -210,13 +210,18 @@ public class SeriesIDs : IDs public List<int> TvDB { get; set; } = new List<int>(); - public List<int> TMDB { get; set; } = new List<int>(); + public TmdbSeriesIDs TMDB { get; set; } = new(); public List<int> MAL { get; set; } = new List<int>(); public List<string> TraktTv { get; set; } = new List<string>(); - public List<int> AniList { get; set; } = new List<int>(); + public class TmdbSeriesIDs + { + public List<int> Movie { get; init; } = new List<int>(); + + public List<int> Show { get; init; } = new List<int>(); + } } /// <summary> diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index ce5884c3..e42a06ac 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -148,7 +148,7 @@ private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Model { foreach (var image in images.Posters.OrderByDescending(image => image.IsDefault)) AddImage(ref list, ImageType.Primary, image); - foreach (var image in images.Fanarts.OrderByDescending(image => image.IsDefault)) + foreach (var image in images.Backdrops.OrderByDescending(image => image.IsDefault)) AddImage(ref list, ImageType.Backdrop, image); foreach (var image in images.Banners.OrderByDescending(image => image.IsDefault)) AddImage(ref list, ImageType.Banner, image); From 1d4705e9d668c46eda271d70595fde92c83f983f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 8 Aug 2024 14:03:05 +0200 Subject: [PATCH 006/144] =?UTF-8?q?fix:=20fix=20models=20for=20stable=20se?= =?UTF-8?q?rver=20=E2=80=A6because=20we=20shouldn't=20break=20those=20yet.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/API/Models/Image.cs | 2 +- Shokofin/API/Models/Images.cs | 15 ++++++++++++--- Shokofin/API/Models/Series.cs | 19 ++----------------- Shokofin/Providers/ImageProvider.cs | 4 +++- 4 files changed, 18 insertions(+), 22 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index b20c9bae..1f9f5833 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -19,9 +19,9 @@ public class Image /// The image's id. Usually an int, but in the case of <see cref="ImageType.Static"/> resources /// then it is the resource name. /// </summary> + [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public int ID { get; set; } = 0; - /// <summary> /// True if the image is marked as the default for the given <see cref="ImageType"/>. /// Only one default is possible for a given <see cref="ImageType"/>. diff --git a/Shokofin/API/Models/Images.cs b/Shokofin/API/Models/Images.cs index 894faf56..6b5f6cbd 100644 --- a/Shokofin/API/Models/Images.cs +++ b/Shokofin/API/Models/Images.cs @@ -4,9 +4,18 @@ namespace Shokofin.API.Models; public class Images { - public List<Image> Posters { get; set; } = new List<Image>(); + public List<Image> Posters { get; set; } = []; - public List<Image> Backdrops { get; set; } = new List<Image>(); + public List<Image> Backdrops { get; set; } = []; - public List<Image> Banners { get; set; } = new List<Image>(); + // Backwards compatibility with stable 4.2.2.0 server. + public List<Image> Fanarts + { + get => Backdrops; + set => Backdrops = value; + } + + public List<Image> Banners { get; set; } = []; + + public List<Image> Logos { get; set; } = []; } diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 7288cdcc..76a58c59 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -39,7 +39,7 @@ public class Series /// The TvDB entries, if any. /// </summary> [JsonPropertyName("TvDB")] - public List<TvDB> TvDBEntityList { get; set; }= new(); + public List<TvDB> TvDBEntityList { get; set; }= []; public SeriesSizes Sizes { get; set; } = new(); @@ -132,7 +132,7 @@ public class AniDBWithDate : AniDB /// <summary> /// There should always be at least one of these, the <see cref="Title"/>. May be omitted if needed. /// </summary> - public new List<Title> Titles { get; set; } = new(); + public new List<Title> Titles { get; set; } = []; /// <summary> /// The average rating for the anime. Only available on @@ -207,21 +207,6 @@ public class SeriesIDs : IDs public int TopLevelGroup { get; set; } = 0; public int AniDB { get; set; } = 0; - - public List<int> TvDB { get; set; } = new List<int>(); - - public TmdbSeriesIDs TMDB { get; set; } = new(); - - public List<int> MAL { get; set; } = new List<int>(); - - public List<string> TraktTv { get; set; } = new List<string>(); - - public class TmdbSeriesIDs - { - public List<int> Movie { get; init; } = new List<int>(); - - public List<int> Show { get; init; } = new List<int>(); - } } /// <summary> diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index e42a06ac..a72e56f1 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -152,11 +152,13 @@ private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Model AddImage(ref list, ImageType.Backdrop, image); foreach (var image in images.Banners.OrderByDescending(image => image.IsDefault)) AddImage(ref list, ImageType.Banner, image); + foreach (var image in images.Logos.OrderByDescending(image => image.IsDefault)) + AddImage(ref list, ImageType.Logo, image); } private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image? image) { - if (image == null || !image.IsAvailable) + if (image == null) return; list.Add(new RemoteImageInfo { ProviderName = Plugin.MetadataProviderName, From 3163facbc59c1c0b3a8cc6d3fe4a4215d8690d43 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 8 Aug 2024 14:04:08 +0200 Subject: [PATCH 007/144] misc: remove static image type [skip ci] --- Shokofin/API/Models/Image.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 1f9f5833..0c902621 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -16,8 +16,7 @@ public class Image public ImageType Type { get; set; } = ImageType.Poster; /// <summary> - /// The image's id. Usually an int, but in the case of <see cref="ImageType.Static"/> resources - /// then it is the resource name. + /// The image's id. /// </summary> [JsonNumberHandling(JsonNumberHandling.AllowReadingFromString)] public int ID { get; set; } = 0; @@ -144,9 +143,4 @@ public enum ImageType /// Clear-text logo. /// </summary> Logo = 7, - - /// <summary> - /// Static resources are only valid if the <see cref="Image.Source"/> is set to <see cref="ImageSource.Shoko"/>. - /// </summary> - Static = 100 } From 4d30335f6098cb809527a9ebccfc34ef3b9ccac4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 8 Aug 2024 14:30:01 +0200 Subject: [PATCH 008/144] =?UTF-8?q?misc:=20update=20git=20to=20json=20help?= =?UTF-8?q?er=20=E2=80=A6to=20fix=20this=20exact=20message=20in=20the=20ch?= =?UTF-8?q?angelog.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit [skip ci] --- .github/workflows/git-log-json.mjs | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/git-log-json.mjs b/.github/workflows/git-log-json.mjs index b1b72548..40b9916c 100755 --- a/.github/workflows/git-log-json.mjs +++ b/.github/workflows/git-log-json.mjs @@ -10,6 +10,13 @@ const RangeOrHash = process.argv[2] || ""; // Form the git log command const GitLogCommandBase = `git log ${RangeOrHash}`; +const EndingMarkers = new Set([ + ".", + ",", + "!", + "?", +]); + const Placeholders = { "H": "commit", "P": "parents", @@ -72,7 +79,7 @@ for (const commit of Object.values(commits)) { } // Convert commits object to a list of values -const commitsList = commitOrder.slice().reverse() +const commitsList = commitOrder.reverse() .map((commitId) => commits[commitId]) .map(({ commit, parents, tree, subject, body, author_name, author_email, author_date, committer_name, committer_email, committer_date }) => ({ commit, @@ -104,14 +111,19 @@ const commitsList = commitOrder.slice().reverse() })) .map((commit) => ({ ...commit, - subject: /[a-z]/.test(commit.subject[0]) ? commit.subject[0].toUpperCase() + commit.subject.slice(1) : commit.subject, + subject: commit.subject.replace(/\[(?:skip|no) *ci\]/ig, "").trim().replace(/[\.:]+^/, ""), + body: commit.body ? commit.body.replace(/\[(?:skip|no) *ci\]/ig, "").trimEnd() : commit.body, + isSkipCI: /\[(?:skip|no) *ci\]/i.test(commit.subject) || Boolean(commit.body && /\[(?:skip|no) *ci\]/i.test(commit.body)), type: commit.type == "feature" ? "feat" : commit.type === "refacor" ? "refactor" : commit.type == "mics" ? "misc" : commit.type, })) .map((commit) => ({ ...commit, - subject: commit.subject.replace(/\[(?:skip|no) *ci\]/ig, "").trim().replace(/[\.:]+^/, ""), - body: commit.body ? commit.body.replace(/\[(?:skip|no) *ci\]/ig, "").trimEnd() : commit.body, - isSkipCI: /\[(?:skip|no) *ci\]/i.test(commit.subject) || Boolean(commit.body && /\[(?:skip|no) *ci\]/i.test(commit.body)), + subject: ((subject) => { + subject = (/[a-z]/.test(subject[0]) ? subject[0].toUpperCase() + subject.slice(1) : subject).trim(); + if (subject.length > 0 && EndingMarkers.has(subject[subject.length - 1])) + subject = subject.slice(0, subject.length - 1); + return subject; + })(commit.subject), })) .filter((commit) => !(commit.type === "misc" && (commit.subject === "update unstable manifest" || commit.subject === "Update repo manifest" || commit.subject === "Update unstable repo manifest"))); From cf9c31db01bf055ba2942c53a8abb3cbb99221d4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 12 Aug 2024 01:34:36 +0200 Subject: [PATCH 009/144] misc: update image DTO model - Renamed `IsDefault` to `IsPreferred` on the image DTO model. - Added `Thumbnail` as a synonym to `Thumb`. - Updated descriptions on image DTO model. --- Shokofin/API/Models/Image.cs | 17 ++++++++++++----- Shokofin/Providers/ImageProvider.cs | 8 ++++---- 2 files changed, 16 insertions(+), 9 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 0c902621..c1974b34 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -22,14 +22,16 @@ public class Image public int ID { get; set; } = 0; /// <summary> - /// True if the image is marked as the default for the given <see cref="ImageType"/>. - /// Only one default is possible for a given <see cref="ImageType"/>. + /// True if the image is marked as the preferred for the given + /// <see cref="ImageType"/>. Only one preferred is possible for a given + /// <see cref="ImageType"/>. /// </summary> [JsonPropertyName("Preferred")] - public bool IsDefault { get; set; } = false; + public bool IsPreferred { get; set; } = false; /// <summary> - /// True if the image has been disabled. You must explicitly ask for these, for obvious reasons. + /// True if the image has been disabled. You must explicitly ask for these, + /// for hopefully obvious reasons. /// </summary> [JsonPropertyName("Disabled")] public bool IsDisabled { get; set; } = false; @@ -117,7 +119,12 @@ public enum ImageType /// <summary> /// /// </summary> - Thumb = 3, + Thumbnail = 3, + + /// <summary> + /// + /// </summary> + Thumb = Thumbnail, /// <summary> /// diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index a72e56f1..54ee58fa 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -146,13 +146,13 @@ private static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) { - foreach (var image in images.Posters.OrderByDescending(image => image.IsDefault)) + foreach (var image in images.Posters.OrderByDescending(image => image.IsPreferred)) AddImage(ref list, ImageType.Primary, image); - foreach (var image in images.Backdrops.OrderByDescending(image => image.IsDefault)) + foreach (var image in images.Backdrops.OrderByDescending(image => image.IsPreferred)) AddImage(ref list, ImageType.Backdrop, image); - foreach (var image in images.Banners.OrderByDescending(image => image.IsDefault)) + foreach (var image in images.Banners.OrderByDescending(image => image.IsPreferred)) AddImage(ref list, ImageType.Banner, image); - foreach (var image in images.Logos.OrderByDescending(image => image.IsDefault)) + foreach (var image in images.Logos.OrderByDescending(image => image.IsPreferred)) AddImage(ref list, ImageType.Logo, image); } From 8cf2b11cb0937bfd77d2a8e631a1ea8a14615967 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 01:00:01 +0200 Subject: [PATCH 010/144] fix: advertise support for logos --- Shokofin/Providers/ImageProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 54ee58fa..0a6a30b6 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -170,7 +170,7 @@ private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType } public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - => new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner }; + => new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner, ImageType.Logo }; public bool Supports(BaseItem item) => item is Series or Season or Episode or Movie or BoxSet; From 8a06ac5d8569094b583b49635713a40aae3bd4b3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 01:12:00 +0200 Subject: [PATCH 011/144] fix: fix public url not being saved --- Shokofin/Configuration/configController.js | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 28028be7..c2838275 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -276,11 +276,6 @@ async function defaultSubmit(form) { let config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); if (config.ApiKey !== "") { - let publicUrl = form.querySelector("#PublicUrl").value; - if (publicUrl.endsWith("/")) { - publicUrl = publicUrl.slice(0, -1); - form.querySelector("#PublicUrl").value = publicUrl; - } const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); // Metadata settings @@ -445,11 +440,11 @@ async function defaultSubmit(form) { publicUrl = publicUrl.slice(0, -1); form.querySelector("#PublicUrl").value = publicUrl; } - config.PublicUrl = publicUrl; // Update the url if needed. if (config.Url !== url || config.PublicUrl !== publicUrl) { config.Url = url; + config.PublicUrl = publicUrl; form.querySelector("#Url").value = url; form.querySelector("#PublicUrl").value = publicUrl; let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); From 76d790d80703ba22ff1ae79c269b276383db2a73 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 01:14:01 +0200 Subject: [PATCH 012/144] fix: fix duplication bug in drop down menus --- Shokofin/Configuration/configController.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index c2838275..4db97416 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -1017,7 +1017,7 @@ export default function (page) { form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; - mediaFolderSelector.innerHTML += config.MediaFolders + mediaFolderSelector.innerHTML = config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); @@ -1025,14 +1025,14 @@ export default function (page) { form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); - signalrMediaFolderSelector.innerHTML += config.MediaFolders + signalrMediaFolderSelector.innerHTML = config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; // User settings - userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); + userSelector.innerHTML = users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); // Experimental settings form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; From 006e955d1c050a3d8788e424c8f351ad9424be7a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 01:15:34 +0200 Subject: [PATCH 013/144] misc: always show delete media folder mapping button --- Shokofin/Configuration/configController.js | 7 ------- Shokofin/Configuration/configPage.html | 2 +- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 4db97416..de0326d2 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -177,13 +177,6 @@ async function loadMediaFolderConfig(form, mediaFolderId, config) { form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); - if (mediaFolderConfig.IsMapped && mediaFolderConfig.LibraryName) { - form.querySelector("#MediaFolderDeleteContainer").setAttribute("hidden", ""); - } - else { - form.querySelector("#MediaFolderDeleteContainer").removeAttribute("hidden"); - } - Dashboard.hideLoadingMsg(); } diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 79bd3f86..235db113 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1354,7 +1354,7 @@ <h3>Media Folder Settings</h3> </div> </div> </div> - <div id="MediaFolderDeleteContainer" class="inputContainer inputContainer-withDescription" hidden> + <div class="inputContainer inputContainer-withDescription" hidden> <button is="emby-button" type="submit" name="remove-media-folder" class="raised button-delete block emby-button"> <span>Delete</span> </button> From 6cfb7aca7c8d3e27f9fa140dc38954bf60712544 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 01:20:47 +0200 Subject: [PATCH 014/144] revert: "fix: fix duplication bug in drop down menus" --- Shokofin/Configuration/configController.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index de0326d2..247b0601 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -1010,7 +1010,7 @@ export default function (page) { form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; - mediaFolderSelector.innerHTML = config.MediaFolders + mediaFolderSelector.innerHTML += config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); @@ -1018,14 +1018,14 @@ export default function (page) { form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); - signalrMediaFolderSelector.innerHTML = config.MediaFolders + signalrMediaFolderSelector.innerHTML += config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; // User settings - userSelector.innerHTML = users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); + userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); // Experimental settings form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked = config.EXPERIMENTAL_AutoMergeVersions || false; From cfb7dc88e9230058fd1b5dbdd399dc5964b24011 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Tue, 13 Aug 2024 15:12:05 +0200 Subject: [PATCH 015/144] misc: update read-me --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index b277f1b2..50dc50b5 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ compatible with what. | `2.x.x` | `10.8` | `4.1.2` | | `3.x.x` | `10.8` | `4.2.0` | | `4.x.x` | `10.9` | `4.2.2` | -| `dev` | `10.9` | `4.2.2` | +| `dev` | `10.9` | `dev` | ### Official Repository From f7c361fbb24b52e01b8963f9f58538fa6ee71074 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 22:51:52 +0200 Subject: [PATCH 016/144] fix: better base url for `Person` images & in general. Closes #67 --- Shokofin/API/Info/SeasonInfo.cs | 2 +- Shokofin/API/Models/Image.cs | 4 ++-- Shokofin/Plugin.cs | 20 +++++++++++++++++++- Shokofin/Web/ImageHostUrl.cs | 10 ++++++++-- 4 files changed, 30 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index c2368494..5cdf35a0 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -266,7 +266,7 @@ public bool IsEmpty(int offset = 0) } private static string? GetImagePath(Image image) - => image != null && image.IsAvailable ? image.ToURLString() : null; + => image != null && image.IsAvailable ? image.ToURLString(internalUrl: true) : null; private static PersonInfo? RoleToPersonInfo(Role role) => role.Type switch diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index c1974b34..d079fab8 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -69,8 +69,8 @@ public virtual bool IsAvailable /// set up, but better than nothing. /// </remarks> /// <returns>The image URL</returns> - public string ToURLString() - => new Uri(new Uri(Web.ImageHostUrl.BaseUrl), $"{Web.ImageHostUrl.BasePath}/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); + public string ToURLString(bool internalUrl = false) + => new Uri(new Uri(internalUrl ? Plugin.Instance.BaseUrl : Web.ImageHostUrl.BaseUrl), $"{Web.ImageHostUrl.BasePath}/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); } /// <summary> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 61bcaa3f..b9f702d6 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -4,7 +4,9 @@ using System.Linq; using System.Runtime.InteropServices; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; using MediaBrowser.Common.Plugins; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; @@ -15,6 +17,8 @@ namespace Shokofin; public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { + private readonly IServerConfigurationManager ConfigurationManager; + public const string MetadataProviderName = "Shoko"; public override string Name => MetadataProviderName; @@ -38,13 +42,27 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages /// </summary> public readonly string VirtualRoot; + /// <summary> + /// Base URL where the Jellyfin is running. + /// </summary> + public string BaseUrl => ConfigurationManager.GetNetworkConfiguration() is { } networkOptions + ? $"{ + (networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http") + }://{ + (networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" ? address : "localhost") + }:{ + (networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort) + }/" + : "http://localhost:8096/"; + /// <summary> /// Gets or sets the event handler that is triggered when this configuration changes. /// </summary> public new event EventHandler<PluginConfiguration>? ConfigurationChanged; - public Plugin(ILoggerFactory loggerFactory, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) + public Plugin(ILoggerFactory loggerFactory, IServerConfigurationManager configurationManager, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { + ConfigurationManager = configurationManager; Instance = this; base.ConfigurationChanged += OnConfigChanged; VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); diff --git a/Shokofin/Web/ImageHostUrl.cs b/Shokofin/Web/ImageHostUrl.cs index 27cf24f5..f31330d5 100644 --- a/Shokofin/Web/ImageHostUrl.cs +++ b/Shokofin/Web/ImageHostUrl.cs @@ -11,10 +11,16 @@ namespace Shokofin.Web; /// </summary> public class ImageHostUrl : IAsyncActionFilter { + /// <summary> + /// The internal base url. Will be null if the base url haven't been used + /// yet. + /// </summary> + private static string? InternalBaseUrl { get; set; } = null; + /// <summary> /// The current image host base url to use. /// </summary> - public static string BaseUrl { get; private set; } = "http://localhost:8096/"; + public static string BaseUrl { get => InternalBaseUrl ??= Plugin.Instance.BaseUrl; } /// <summary> /// The current image host base path to use. @@ -37,7 +43,7 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE var uri = uriBuilder.ToString(); lock (LockObj) { if (!string.Equals(uri, BaseUrl)) - BaseUrl = uri; + InternalBaseUrl = uri; if (!string.Equals(path, BasePath)) BasePath = path; } From 87ef8f57100aa257f612dcd2291afcc5accee5f4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 22:52:11 +0200 Subject: [PATCH 017/144] fix: add back and fix is available check --- Shokofin/API/Models/Image.cs | 2 +- Shokofin/Providers/ImageProvider.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index d079fab8..edc81d2b 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -58,7 +58,7 @@ public class Image /// </summary> [JsonIgnore] public virtual bool IsAvailable - => !string.IsNullOrEmpty(LocalPath); + => !string.IsNullOrEmpty(LocalPath) || (Plugin.Instance.Configuration.ServerVersion?.Version is {} version && version is not "4.2.2.0"); /// <summary> /// Get an URL to both download the image on the backend and preview it for diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 0a6a30b6..44244cad 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -158,7 +158,7 @@ private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Model private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image? image) { - if (image == null) + if (image == null || !image.IsAvailable) return; list.Add(new RemoteImageInfo { ProviderName = Plugin.MetadataProviderName, From bf4700ad68e68fd50e7c39528e4ef796b80bf8cc Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 22:52:26 +0200 Subject: [PATCH 018/144] misc: xml ignore pretty url --- Shokofin/Configuration/PluginConfiguration.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 09bd0d2d..a423d046 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -49,6 +49,7 @@ public class PluginConfiguration : BasePluginConfiguration [XmlElement("PublicHost")] public string PublicUrl { get; set; } + [XmlIgnore] [JsonIgnore] public virtual string PrettyUrl => string.IsNullOrEmpty(PublicUrl) ? Url : PublicUrl; From 172e788c5bda68a98b7b396a5159468784725496 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 13 Aug 2024 22:53:00 +0200 Subject: [PATCH 019/144] misc: don't set public url on page twice when changed --- Shokofin/Configuration/configController.js | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 247b0601..d4dd42b4 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -431,7 +431,6 @@ async function defaultSubmit(form) { let publicUrl = form.querySelector("#PublicUrl").value; if (publicUrl.endsWith("/")) { publicUrl = publicUrl.slice(0, -1); - form.querySelector("#PublicUrl").value = publicUrl; } // Update the url if needed. From 3806a6f80f72a60a3ce5d87c5ed0d76c3ed28272 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 17 Aug 2024 02:41:55 +0200 Subject: [PATCH 020/144] fix: remove all unused tvdb fields Closes #69 (nice) --- Shokofin/API/Models/Episode.cs | 12 ------------ Shokofin/API/Models/Series.cs | 14 -------------- 2 files changed, 26 deletions(-) diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index 912ed3b9..ae4d81b2 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -75,32 +75,20 @@ public class AniDB public class TvDB { - [JsonPropertyName("ID")] - public int Id { get; set; } - [JsonPropertyName("Season")] public int SeasonNumber { get; set; } [JsonPropertyName("Number")] public int EpisodeNumber { get; set; } - [JsonPropertyName("AbsoluteNumber")] - public int AbsoluteEpisodeNumber { get; set; } - - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - public DateTime? AirDate { get; set; } - public int? AirsAfterSeason { get; set; } public int? AirsBeforeSeason { get; set; } public int? AirsBeforeEpisode { get; set; } - public Rating? Rating { get; set; } - public Image Thumbnail { get; set; } = new(); } diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 76a58c59..91ded7d2 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -183,21 +183,7 @@ public DateTime? EndDate public class TvDB { - /// <summary> - /// TvDB Id. - /// </summary> - [JsonPropertyName("ID")] - public int Id { get; set; } - - public DateTime? AirDate { get; set; } - - public DateTime? EndDate { get; set; } - - public string Title { get; set; } = string.Empty; - public string Description { get; set; } = string.Empty; - - public Rating Rating { get; set; } = new(); } public class SeriesIDs : IDs From fddf8d052ca2c9d9f8aa626b41f6917172d90dc9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 18 Aug 2024 16:25:02 +0200 Subject: [PATCH 021/144] fix: add base path and cache url for 15m --- Shokofin/Plugin.cs | 62 +++++++++++++++++++++++++++++++--------------- 1 file changed, 42 insertions(+), 20 deletions(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index b9f702d6..b98013a1 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -17,7 +17,44 @@ namespace Shokofin; public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { - private readonly IServerConfigurationManager ConfigurationManager; + private static TimeSpan BaseUrlUpdateDelay => TimeSpan.FromMinutes(15); + + private readonly IServerConfigurationManager _configurationManager; + + private readonly ILogger<Plugin> _logger; + + private DateTime? _lastBaseUrlUpdate = null; + + private string? _baseUrl = null; + + /// <summary> + /// Base URL where the Jellyfin is running. + /// </summary> + public string BaseUrl + { + get + { + if (_baseUrl is not null && _lastBaseUrlUpdate is not null && DateTime.Now - _lastBaseUrlUpdate < BaseUrlUpdateDelay) + return _baseUrl; + + _lastBaseUrlUpdate = DateTime.Now; + if (_configurationManager.GetNetworkConfiguration() is not { } networkOptions) + { + _baseUrl = "http://localhost:8096/"; + return _baseUrl; + } + + var protocol = networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http"; + var hostname = networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" and not "::" ? address : "localhost"; + var port = networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort; + var basePath = networkOptions.BaseUrl is { } baseUrl ? baseUrl : string.Empty; + if (basePath.Length > 0 && basePath[0] != '/') + basePath = "/" + basePath; + _baseUrl = new UriBuilder(protocol, hostname, port, basePath).ToString(); + return _baseUrl; + } + } + public const string MetadataProviderName = "Shoko"; @@ -35,26 +72,11 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages /// </summary> public readonly UsageTracker Tracker; - private readonly ILogger<Plugin> Logger; - /// <summary> /// "Virtual" File System Root Directory. /// </summary> public readonly string VirtualRoot; - /// <summary> - /// Base URL where the Jellyfin is running. - /// </summary> - public string BaseUrl => ConfigurationManager.GetNetworkConfiguration() is { } networkOptions - ? $"{ - (networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http") - }://{ - (networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" ? address : "localhost") - }:{ - (networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort) - }/" - : "http://localhost:8096/"; - /// <summary> /// Gets or sets the event handler that is triggered when this configuration changes. /// </summary> @@ -62,12 +84,12 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages public Plugin(ILoggerFactory loggerFactory, IServerConfigurationManager configurationManager, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { - ConfigurationManager = configurationManager; + _configurationManager = configurationManager; Instance = this; base.ConfigurationChanged += OnConfigChanged; VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); Tracker = new(loggerFactory.CreateLogger<UsageTracker>(), TimeSpan.FromSeconds(60)); - Logger = logger; + _logger = logger; CanCreateSymbolicLinks = true; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var target = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestTarget.txt"); @@ -90,8 +112,8 @@ public Plugin(ILoggerFactory loggerFactory, IServerConfigurationManager configur } IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); - Logger.LogDebug("Virtual File System Location; {Path}", VirtualRoot); - Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); + _logger.LogDebug("Virtual File System Location; {Path}", VirtualRoot); + _logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); } public void UpdateConfiguration() From b720a3904505a83a71af9634c1634a63cc48464f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 18 Aug 2024 16:40:29 +0200 Subject: [PATCH 022/144] fix: split base path from base url --- Shokofin/API/Models/Image.cs | 2 +- Shokofin/Plugin.cs | 96 +++++++++++++++++++++++++++--------- Shokofin/Web/ImageHostUrl.cs | 14 ++++-- 3 files changed, 83 insertions(+), 29 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index edc81d2b..f34f09bf 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -70,7 +70,7 @@ public virtual bool IsAvailable /// </remarks> /// <returns>The image URL</returns> public string ToURLString(bool internalUrl = false) - => new Uri(new Uri(internalUrl ? Plugin.Instance.BaseUrl : Web.ImageHostUrl.BaseUrl), $"{Web.ImageHostUrl.BasePath}/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); + => new Uri(new Uri(internalUrl ? Plugin.Instance.BaseUrl : Web.ImageHostUrl.BaseUrl), $"{(internalUrl ? Plugin.Instance.BasePath : Web.ImageHostUrl.BasePath)}/Plugin/Shokofin/Host/Image/{Source}/{Type}/{ID}").ToString(); } /// <summary> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index b98013a1..7af4026a 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -21,40 +21,88 @@ public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages private readonly IServerConfigurationManager _configurationManager; - private readonly ILogger<Plugin> _logger; + private readonly ILogger<Plugin> Logger; - private DateTime? _lastBaseUrlUpdate = null; + /// <summary> + /// The last time the base URL and base path was updated. + /// </summary> + private DateTime? LastBaseUrlUpdate = null; - private string? _baseUrl = null; + /// <summary> + /// Cached base URL of the Jellyfin server, to avoid calculating it all the + /// time. + /// </summary> + private string? CachedBaseUrl = null; /// <summary> - /// Base URL where the Jellyfin is running. + /// Base URL where the Jellyfin server is running. /// </summary> public string BaseUrl { get { - if (_baseUrl is not null && _lastBaseUrlUpdate is not null && DateTime.Now - _lastBaseUrlUpdate < BaseUrlUpdateDelay) - return _baseUrl; - - _lastBaseUrlUpdate = DateTime.Now; - if (_configurationManager.GetNetworkConfiguration() is not { } networkOptions) - { - _baseUrl = "http://localhost:8096/"; - return _baseUrl; + if (CachedBaseUrl is not null && LastBaseUrlUpdate is not null && DateTime.Now - LastBaseUrlUpdate < BaseUrlUpdateDelay) + return CachedBaseUrl; + + lock(this) { + LastBaseUrlUpdate = DateTime.Now; + if (_configurationManager.GetNetworkConfiguration() is not { } networkOptions) + { + CachedBaseUrl = "http://localhost:8096/"; + CachedBasePath = string.Empty; + return CachedBaseUrl; + } + + var protocol = networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http"; + var hostname = networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" and not "::" ? address : "localhost"; + var port = networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort; + var basePath = networkOptions.BaseUrl is { } baseUrl ? baseUrl : string.Empty; + if (basePath.Length > 0 && basePath[0] == '/') + basePath = basePath[1..]; + CachedBaseUrl = new UriBuilder(protocol, hostname, port).ToString(); + CachedBasePath = basePath; + return CachedBaseUrl; } - - var protocol = networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http"; - var hostname = networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" and not "::" ? address : "localhost"; - var port = networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort; - var basePath = networkOptions.BaseUrl is { } baseUrl ? baseUrl : string.Empty; - if (basePath.Length > 0 && basePath[0] != '/') - basePath = "/" + basePath; - _baseUrl = new UriBuilder(protocol, hostname, port, basePath).ToString(); - return _baseUrl; } } + /// <summary> + /// Cached base path of the Jellyfin server, to avoid calculating it all the + /// time. + /// </summary> + private string? CachedBasePath = null; + + /// <summary> + /// Base path where the Jellyfin server is running on the domain. + /// </summary> + public string BasePath + { + get + { + if (CachedBasePath is not null && LastBaseUrlUpdate is not null && DateTime.Now - LastBaseUrlUpdate < BaseUrlUpdateDelay) + return CachedBasePath; + + lock(this) { + LastBaseUrlUpdate = DateTime.Now; + if (_configurationManager.GetNetworkConfiguration() is not { } networkOptions) + { + CachedBaseUrl = "http://localhost:8096/"; + CachedBasePath = string.Empty; + return CachedBaseUrl; + } + + var protocol = networkOptions.RequireHttps && networkOptions.EnableHttps ? "https" : "http"; + var hostname = networkOptions.LocalNetworkAddresses.FirstOrDefault() is { } address && address is not "0.0.0.0" and not "::" ? address : "localhost"; + var port = networkOptions.RequireHttps && networkOptions.EnableHttps ? networkOptions.InternalHttpsPort : networkOptions.InternalHttpPort; + var basePath = networkOptions.BaseUrl is { } baseUrl ? baseUrl : string.Empty; + if (basePath.Length > 0 && basePath[0] == '/') + basePath = basePath[1..]; + CachedBaseUrl = new UriBuilder(protocol, hostname, port).ToString(); + CachedBasePath = basePath; + return CachedBasePath; + } + } + } public const string MetadataProviderName = "Shoko"; @@ -89,7 +137,7 @@ public Plugin(ILoggerFactory loggerFactory, IServerConfigurationManager configur base.ConfigurationChanged += OnConfigChanged; VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); Tracker = new(loggerFactory.CreateLogger<UsageTracker>(), TimeSpan.FromSeconds(60)); - _logger = logger; + Logger = logger; CanCreateSymbolicLinks = true; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var target = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestTarget.txt"); @@ -112,8 +160,8 @@ public Plugin(ILoggerFactory loggerFactory, IServerConfigurationManager configur } IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); - _logger.LogDebug("Virtual File System Location; {Path}", VirtualRoot); - _logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); + Logger.LogDebug("Virtual File System Location; {Path}", VirtualRoot); + Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); } public void UpdateConfiguration() diff --git a/Shokofin/Web/ImageHostUrl.cs b/Shokofin/Web/ImageHostUrl.cs index f31330d5..4810a434 100644 --- a/Shokofin/Web/ImageHostUrl.cs +++ b/Shokofin/Web/ImageHostUrl.cs @@ -22,10 +22,16 @@ public class ImageHostUrl : IAsyncActionFilter /// </summary> public static string BaseUrl { get => InternalBaseUrl ??= Plugin.Instance.BaseUrl; } + /// <summary> + /// The internal base path. Will be null if the base path haven't been used + /// yet. + /// </summary> + private static string? InternalBasePath { get; set; } = null; + /// <summary> /// The current image host base path to use. /// </summary> - public static string BasePath { get; private set; } = ""; + public static string BasePath { get => InternalBasePath ??= Plugin.Instance.BasePath; } private readonly object LockObj = new(); @@ -42,10 +48,10 @@ public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionE uriBuilder.Query = ""; var uri = uriBuilder.ToString(); lock (LockObj) { - if (!string.Equals(uri, BaseUrl)) + if (!string.Equals(uri, InternalBaseUrl)) InternalBaseUrl = uri; - if (!string.Equals(path, BasePath)) - BasePath = path; + if (!string.Equals(path, InternalBasePath)) + InternalBasePath = path; } } await next(); From 56e30c419e5c79a2b4e973bc9d881e3cfaa5fafd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 18 Aug 2024 20:52:16 +0200 Subject: [PATCH 023/144] fix: fix thumbnails for stable and dev servers --- Shokofin/API/Models/Images.cs | 5 +++++ Shokofin/API/ShokoAPIClient.cs | 28 ++++++++++++++++++++++++++-- Shokofin/Providers/ImageProvider.cs | 27 +++++++++++---------------- 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/Shokofin/API/Models/Images.cs b/Shokofin/API/Models/Images.cs index 6b5f6cbd..1283c7fe 100644 --- a/Shokofin/API/Models/Images.cs +++ b/Shokofin/API/Models/Images.cs @@ -19,3 +19,8 @@ public List<Image> Fanarts public List<Image> Logos { get; set; } = []; } + +public class EpisodeImages : Images +{ + public List<Image> Thumbnails { get; set; } = []; +} \ No newline at end of file diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index cebe3456..26dc4f1d 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Generic; +using System.Linq; using System.Net; using System.Net.Http; using System.Text; @@ -34,6 +35,9 @@ public class ShokoAPIClient : IDisposable private static bool UseOlderImportFolderFileEndpoints => ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < ImportFolderCutOffDate)); + public static bool AllowEpisodeImages => + ServerVersion is { } serverVersion && serverVersion.Version[0..6] is not "4.2.2." && serverVersion.Version.Split('.').Last() is not "0"; + private readonly GuardedMemoryCache _cache; public ShokoAPIClient(ILogger<ShokoAPIClient> logger) @@ -336,6 +340,21 @@ public Task<Episode> GetEpisode(string id) return Get<Episode>($"/api/v3/Episode/{id}?includeDataFrom=AniDB,TvDB&includeXRefs=true"); } + public async Task<EpisodeImages?> GetEpisodeImages(string id) + { + try { + if (AllowEpisodeImages) + return await Get<EpisodeImages>($"/api/v3/Episode/{id}/Images"); + var episode = await GetEpisode(id); + return new() { + Thumbnails = episode.TvDBEntityList.FirstOrDefault()?.Thumbnail is { } thumbnail ? [thumbnail] : [], + }; + } + catch (ApiException e) when (e.StatusCode == HttpStatusCode.NotFound) { + return null; + } + } + public Task<ListResult<Episode>> GetEpisodesFromSeries(string seriesId) { return Get<ListResult<Episode>>($"/api/v3/Series/{seriesId}/Episode?pageSize=0&includeHidden=true&includeMissing=true&includeDataFrom=AniDB,TvDB&includeXRefs=true"); @@ -366,9 +385,14 @@ public Task<List<Relation>> GetSeriesRelations(string id) return Get<List<Relation>>($"/api/v3/Series/{id}/Relations"); } - public Task<Images> GetSeriesImages(string id) + public async Task<Images?> GetSeriesImages(string id) { - return Get<Images>($"/api/v3/Series/{id}/Images"); + try { + return await Get<Images>($"/api/v3/Series/{id}/Images"); + } + catch (ApiException e) when (e.StatusCode == HttpStatusCode.NotFound) { + return null; + } } public Task<List<Series>> GetSeriesPathEndsWith(string dirname) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 44244cad..3c9c8c34 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -50,9 +50,9 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell switch (item) { case Episode episode: { if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { - var episodeInfo = await ApiManager.GetEpisodeInfo(episodeId); - if (episodeInfo is not null) - AddImagesForEpisode(ref list, episodeInfo); + var episodeImages = await ApiClient.GetEpisodeImages(episodeId); + if (episodeImages is not null) + AddImagesUsingList(ref list, episodeImages); Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); } break; @@ -61,19 +61,19 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + AddImagesUsingList(ref list, seriesImages); // Also attach any images linked to the "seasons" (AKA series within the group). var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo is not null && !showInfo.IsStandalone) { foreach (var seasonInfo in showInfo.SeasonList) { seriesImages = await ApiClient.GetSeriesImages(seasonInfo.Id); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + AddImagesUsingList(ref list, seriesImages); if (seasonInfo?.ExtraIds is not null) { foreach (var extraId in seasonInfo.ExtraIds) { seriesImages = await ApiClient.GetSeriesImages(extraId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + AddImagesUsingList(ref list, seriesImages); } } } @@ -90,12 +90,12 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + AddImagesUsingList(ref list, seriesImages); if (seasonInfo?.ExtraIds is not null) { foreach (var extraId in seasonInfo.ExtraIds) { seriesImages = await ApiClient.GetSeriesImages(extraId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + AddImagesUsingList(ref list, seriesImages); } list = list .DistinctBy(image => image.Url) @@ -109,7 +109,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetSeriesIdFor(movie, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + AddImagesUsingList(ref list, seriesImages); Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); } break; @@ -122,7 +122,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (!string.IsNullOrEmpty(seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + AddImagesUsingList(ref list, seriesImages); Logger.LogInformation("Getting {Count} images for collection {CollectionName} (Group={GroupId},Series={SeriesId})", list.Count, collection.Name, groupId, groupId == null ? seriesId : null); } break; @@ -139,12 +139,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } } - private static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Info.EpisodeInfo episodeInfo) - { - AddImage(ref list, ImageType.Primary, episodeInfo?.TvDB?.Thumbnail); - } - - private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) + private static void AddImagesUsingList(ref List<RemoteImageInfo> list, API.Models.Images images) { foreach (var image in images.Posters.OrderByDescending(image => image.IsPreferred)) AddImage(ref list, ImageType.Primary, image); From fa2d93fc4662a2704d3710b99829231bd51a050c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 18 Aug 2024 20:59:41 +0200 Subject: [PATCH 024/144] refactor: better version checks --- Shokofin/API/Models/ComponentVersion.cs | 4 +++- Shokofin/API/Models/Image.cs | 2 +- Shokofin/API/ShokoAPIClient.cs | 6 +++--- Shokofin/SignalR/SignalRConnectionManager.cs | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/Shokofin/API/Models/ComponentVersion.cs b/Shokofin/API/Models/ComponentVersion.cs index 9080dd52..915d9c68 100644 --- a/Shokofin/API/Models/ComponentVersion.cs +++ b/Shokofin/API/Models/ComponentVersion.cs @@ -1,4 +1,5 @@ using System; +using System.ComponentModel; using System.Linq; using System.Text.Json.Serialization; @@ -17,7 +18,8 @@ public class ComponentVersion /// <summary> /// Version number. /// </summary> - public string Version { get; set; } = string.Empty; + [DefaultValue("1.0.0.0")] + public Version Version { get; set; } = new("1.0.0.0"); /// <summary> /// Commit SHA. diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index f34f09bf..25213e07 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -58,7 +58,7 @@ public class Image /// </summary> [JsonIgnore] public virtual bool IsAvailable - => !string.IsNullOrEmpty(LocalPath) || (Plugin.Instance.Configuration.ServerVersion?.Version is {} version && version is not "4.2.2.0"); + => !string.IsNullOrEmpty(LocalPath) || (Plugin.Instance.Configuration.ServerVersion?.Version is {} version && version > new Version("4.2.2.0")); /// <summary> /// Get an URL to both download the image on the backend and preview it for diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 26dc4f1d..65250314 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -28,15 +28,15 @@ public class ShokoAPIClient : IDisposable private static readonly DateTime StableCutOffDate = DateTime.Parse("2023-12-16T00:00:00.000Z"); private static bool UseOlderSeriesAndFileEndpoints => - ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < StableCutOffDate)); + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == new Version("4.2.2.0")) || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < StableCutOffDate)); private static readonly DateTime ImportFolderCutOffDate = DateTime.Parse("2024-03-28T00:00:00.000Z"); private static bool UseOlderImportFolderFileEndpoints => - ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < ImportFolderCutOffDate)); + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == new Version("4.2.2.0")) || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < ImportFolderCutOffDate)); public static bool AllowEpisodeImages => - ServerVersion is { } serverVersion && serverVersion.Version[0..6] is not "4.2.2." && serverVersion.Version.Split('.').Last() is not "0"; + ServerVersion is { } serverVersion && serverVersion.Version > new Version("4.2.2.0"); private readonly GuardedMemoryCache _cache; diff --git a/Shokofin/SignalR/SignalRConnectionManager.cs b/Shokofin/SignalR/SignalRConnectionManager.cs index ef75619c..e09f154c 100644 --- a/Shokofin/SignalR/SignalRConnectionManager.cs +++ b/Shokofin/SignalR/SignalRConnectionManager.cs @@ -22,7 +22,7 @@ public class SignalRConnectionManager private static readonly DateTime EventChangedDate = DateTime.Parse("2024-04-01T04:04:00.000Z"); private static bool UseOlderEvents => - ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == "4.2.2.0") || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < EventChangedDate)); + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == new Version("4.2.2.0")) || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < EventChangedDate)); private const string HubUrl = "/signalr/aggregate?feeds=shoko"; From 0d9503ef1146aecb2bc8de7fa0f55418c5ed4e50 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 18 Aug 2024 21:22:18 +0200 Subject: [PATCH 025/144] =?UTF-8?q?fix:=20fix=20thumbnails=E2=80=A6=20agai?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/API/Models/Image.cs | 5 +++++ Shokofin/Providers/ImageProvider.cs | 24 +++++++++++++++--------- 2 files changed, 20 insertions(+), 9 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index 25213e07..d72b2867 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -36,6 +36,11 @@ public class Image [JsonPropertyName("Disabled")] public bool IsDisabled { get; set; } = false; + /// <summary> + /// The language code for the image, if available. + /// </summary> + public string? LanguageCode { get; set; } = null; + /// <summary> /// Width of the image, if available. /// </summary> diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 3c9c8c34..42e95a60 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -52,7 +52,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { var episodeImages = await ApiClient.GetEpisodeImages(episodeId); if (episodeImages is not null) - AddImagesUsingList(ref list, episodeImages); + AddImagesForEpisode(ref list, episodeImages); Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); } break; @@ -61,19 +61,19 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesUsingList(ref list, seriesImages); + AddImagesForSeries(ref list, seriesImages); // Also attach any images linked to the "seasons" (AKA series within the group). var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo is not null && !showInfo.IsStandalone) { foreach (var seasonInfo in showInfo.SeasonList) { seriesImages = await ApiClient.GetSeriesImages(seasonInfo.Id); if (seriesImages is not null) - AddImagesUsingList(ref list, seriesImages); + AddImagesForSeries(ref list, seriesImages); if (seasonInfo?.ExtraIds is not null) { foreach (var extraId in seasonInfo.ExtraIds) { seriesImages = await ApiClient.GetSeriesImages(extraId); if (seriesImages is not null) - AddImagesUsingList(ref list, seriesImages); + AddImagesForSeries(ref list, seriesImages); } } } @@ -90,12 +90,12 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesUsingList(ref list, seriesImages); + AddImagesForSeries(ref list, seriesImages); if (seasonInfo?.ExtraIds is not null) { foreach (var extraId in seasonInfo.ExtraIds) { seriesImages = await ApiClient.GetSeriesImages(extraId); if (seriesImages is not null) - AddImagesUsingList(ref list, seriesImages); + AddImagesForSeries(ref list, seriesImages); } list = list .DistinctBy(image => image.Url) @@ -109,7 +109,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetSeriesIdFor(movie, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesUsingList(ref list, seriesImages); + AddImagesForSeries(ref list, seriesImages); Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); } break; @@ -122,7 +122,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (!string.IsNullOrEmpty(seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesUsingList(ref list, seriesImages); + AddImagesForSeries(ref list, seriesImages); Logger.LogInformation("Getting {Count} images for collection {CollectionName} (Group={GroupId},Series={SeriesId})", list.Count, collection.Name, groupId, groupId == null ? seriesId : null); } break; @@ -139,7 +139,13 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } } - private static void AddImagesUsingList(ref List<RemoteImageInfo> list, API.Models.Images images) + public static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Models.EpisodeImages images) + { + foreach (var image in images.Thumbnails.OrderByDescending(image => image.IsPreferred)) + AddImage(ref list, ImageType.Primary, image); + } + + private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) { foreach (var image in images.Posters.OrderByDescending(image => image.IsPreferred)) AddImage(ref list, ImageType.Primary, image); From c5b0fd411e554ef6ae90d358005e9070b9567f71 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 18 Aug 2024 21:22:29 +0200 Subject: [PATCH 026/144] feat: add image language code --- Shokofin/Providers/ImageProvider.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 42e95a60..bac54cd6 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -163,6 +163,7 @@ private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType return; list.Add(new RemoteImageInfo { ProviderName = Plugin.MetadataProviderName, + Language = image.LanguageCode, Type = imageType, Width = image.Width, Height = image.Height, From fb5fe4c00ee93b0903fd031d81d8c3b4591c158e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 19 Aug 2024 04:42:07 +0200 Subject: [PATCH 027/144] fix: expect the unexpected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Handle the case where no cross-reference exists for the file for the given series, even though we asked for files for the given series. Don't ask me why. I'm just as confused as you are. 😕 - Closes #70 --- Shokofin/API/ShokoAPIManager.cs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index f8cf95be..53047b08 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -360,11 +360,11 @@ public async Task<HashSet<string>> GetLocalEpisodeIdsForSeason(SeasonInfo season var pathSet = new HashSet<string>(); var episodeIds = new HashSet<string>(); foreach (var file in await APIClient.GetFilesForSeries(seriesId).ConfigureAwait(false)) { - if (file.CrossReferences.Count == 1) + if (file.CrossReferences.Count == 1 && file.CrossReferences[0] is { } xref && xref.Series.Shoko.HasValue && xref.Series.Shoko.ToString() == seriesId) foreach (var fileLocation in file.Locations) pathSet.Add((Path.GetDirectoryName(fileLocation.RelativePath) ?? string.Empty) + Path.DirectorySeparatorChar); - var xref = file.CrossReferences.First(xref => xref.Series.Shoko.HasValue && xref.Series.Shoko.ToString() == seriesId); - foreach (var episodeXRef in xref.Episodes.Where(e => e.Shoko.HasValue)) + xref = file.CrossReferences.FirstOrDefault(xref => xref.Series.Shoko.HasValue && xref.Series.Shoko.ToString() == seriesId); + foreach (var episodeXRef in xref?.Episodes.Where(e => e.Shoko.HasValue) ?? []) episodeIds.Add(episodeXRef.Shoko!.Value.ToString()); } From 6886e794d87caff7022412f96c0a51f3827fa3eb Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 19 Aug 2024 04:42:27 +0200 Subject: [PATCH 028/144] fix: add missing query parameter in series file endpoint --- Shokofin/API/ShokoAPIClient.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 65250314..168e4fe9 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -279,7 +279,7 @@ public Task<List<File>> GetFileByPath(string path) public async Task<IReadOnlyList<File>> GetFilesForSeries(string seriesId) { if (UseOlderSeriesAndFileEndpoints) - return await Get<List<File>>($"/api/v3/Series/{seriesId}/File?&includeXRefs=true&includeDataFrom=AniDB").ConfigureAwait(false); + return await Get<List<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&includeXRefs=true&includeDataFrom=AniDB").ConfigureAwait(false); var listResult = await Get<ListResult<File>>($"/api/v3/Series/{seriesId}/File?pageSize=0&include=XRefs&includeDataFrom=AniDB").ConfigureAwait(false); return listResult.List; From e4810000eb72c22bb978db76d020e94350c68e51 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 19 Aug 2024 04:50:50 +0200 Subject: [PATCH 029/144] fix: sanitize collection overview - Sanitize collection overview as if it were a description from AniDB, because 99% of the time it is. - Closes #71 --- Shokofin/Providers/BoxSetProvider.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index a6b4bb1c..1134c197 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -99,7 +99,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupMetadata(BoxSetInfo info result.Item = new BoxSet { Name = collection.Name, - Overview = collection.Shoko.Description, + Overview = Text.SanitizeAnidbDescription(collection.Shoko.Description), }; result.Item.SetProviderId(ShokoCollectionGroupId.Name, collection.Id); result.HasMetadata = true; From 4b92e26d772fbbe2a100206a23b4e5e05f527a0f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 19 Aug 2024 05:02:31 +0200 Subject: [PATCH 030/144] fix: fix specials placement by air date - Fixed specials that should had been placed within a season before all episodes. - Closes #66 --- Shokofin/API/Info/SeasonInfo.cs | 42 +++++++++++++++++++++++---------- Shokofin/Utils/Ordering.cs | 5 ++++ 2 files changed, 35 insertions(+), 12 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 5cdf35a0..eb6a9bec 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -80,6 +80,11 @@ public class SeasonInfo /// </summary> public readonly List<EpisodeInfo> ExtrasList; + /// <summary> + /// A list of special episodes that come before normal episodes. + /// </summary> + public readonly IReadOnlySet<string> SpecialsBeforeEpisodes; + /// <summary> /// A dictionary holding mappings for the previous normal episode for every special episode in a series. /// </summary> @@ -118,6 +123,7 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext .Where(r => r.RelatedIDs.Shoko.HasValue) .DistinctBy(r => r.RelatedIDs.Shoko!.Value) .ToDictionary(r => r.RelatedIDs.Shoko!.Value.ToString(), r => r.Type); + var specialsBeforeEpisodes = new HashSet<string>(); var specialsAnchorDictionary = new Dictionary<EpisodeInfo, EpisodeInfo>(); var specialsList = new List<EpisodeInfo>(); var episodesList = new List<EpisodeInfo>(); @@ -126,7 +132,7 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext // Iterate over the episodes once and store some values for later use. int index = 0; - int lastNormalEpisode = 0; + int lastNormalEpisode = -1; foreach (var episode in episodes) { if (episode.Shoko.IsHidden) continue; @@ -147,11 +153,16 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext } else if (episode.AniDB.Type == EpisodeType.Special) { specialsList.Add(episode); - var previousEpisode = episodes - .GetRange(lastNormalEpisode, index - lastNormalEpisode) - .FirstOrDefault(e => e.AniDB.Type == EpisodeType.Normal); - if (previousEpisode != null) - specialsAnchorDictionary[episode] = previousEpisode; + if (index == -1) { + specialsBeforeEpisodes.Add(episode.Id); + } + else { + var previousEpisode = episodes + .GetRange(lastNormalEpisode, index - lastNormalEpisode) + .FirstOrDefault(e => e.AniDB.Type == EpisodeType.Normal); + if (previousEpisode != null) + specialsAnchorDictionary[episode] = previousEpisode; + } } break; } @@ -191,18 +202,24 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext // Re-create the special anchors because the episode list changed. index = 0; - lastNormalEpisode = 0; + lastNormalEpisode = -1; + specialsBeforeEpisodes.Clear(); specialsAnchorDictionary.Clear(); foreach (var episode in episodes) { if (episodesList.Contains(episode)) { lastNormalEpisode = index; } else if (specialsList.Contains(episode)) { - var previousEpisode = episodes - .GetRange(lastNormalEpisode, index - lastNormalEpisode) - .FirstOrDefault(e => e.AniDB.Type == EpisodeType.Normal); - if (previousEpisode != null) - specialsAnchorDictionary[episode] = previousEpisode; + if (index == -1) { + specialsBeforeEpisodes.Add(episode.Id); + } + else { + var previousEpisode = episodes + .GetRange(lastNormalEpisode, index - lastNormalEpisode) + .FirstOrDefault(e => specialsList.Contains(e)); + if (previousEpisode != null) + specialsAnchorDictionary[episode] = previousEpisode; + } } index++; } @@ -242,6 +259,7 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext EpisodeList = episodesList; AlternateEpisodesList = altEpisodesList; ExtrasList = extrasList; + SpecialsBeforeEpisodes = specialsBeforeEpisodes; SpecialsAnchors = specialsAnchorDictionary; SpecialsList = specialsList; Relations = relations; diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 4b603bea..157c4b4a 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -179,6 +179,11 @@ public static (int?, int?, int?, bool) GetSpecialPlacement(ShowInfo showInfo, Se byAirDate: // Reset the order if we come from `SpecialOrderType.InBetweenSeasonMixed`. episodeNumber = null; + if (seasonInfo.SpecialsBeforeEpisodes.Contains(episodeInfo.Id)) { + airsBeforeSeasonNumber = seasonNumber; + break; + } + if (seasonInfo.SpecialsAnchors.TryGetValue(episodeInfo, out var previousEpisode)) episodeNumber = GetEpisodeNumber(showInfo, seasonInfo, previousEpisode); From b7ca9596ea1689b5bf9e2e50f68d91d60e8c1ac0 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Mon, 19 Aug 2024 18:13:05 +0200 Subject: [PATCH 031/144] fix: fix wrong variable check Closes #72 --- Shokofin/API/Info/SeasonInfo.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index eb6a9bec..59c7517a 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -153,7 +153,7 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext } else if (episode.AniDB.Type == EpisodeType.Special) { specialsList.Add(episode); - if (index == -1) { + if (lastNormalEpisode == -1) { specialsBeforeEpisodes.Add(episode.Id); } else { @@ -210,7 +210,7 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext lastNormalEpisode = index; } else if (specialsList.Contains(episode)) { - if (index == -1) { + if (lastNormalEpisode == -1) { specialsBeforeEpisodes.Add(episode.Id); } else { From 6e4fe6386cc405c998e92fbaf0c380196d62e194 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 19 Aug 2024 23:46:06 +0200 Subject: [PATCH 032/144] fix: check if plugin is enabled for item before looking up id --- Shokofin/Sync/UserDataSyncManager.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index 0fe70faf..f4deeb87 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -191,6 +191,7 @@ public async void OnUserDataSaved(object? sender, UserDataSaveEventArgs e) if (!( (e.Item is Movie || e.Item is Episode) && TryGetUserConfiguration(e.UserId, out var userConfig) && + Lookup.IsEnabledForItem(e.Item) && Lookup.TryGetFileIdFor(e.Item, out var fileId) && Lookup.TryGetEpisodeIdFor(e.Item, out var episodeId) && (userConfig!.SyncRestrictedVideos || e.Item.CustomRating != "XXX") From ea69f418902e153dd674ecac062dd7395ece2a1b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 19 Aug 2024 23:52:04 +0200 Subject: [PATCH 033/144] fix: don't look up crew/cast images if it's not available locally in the server --- Shokofin/API/Info/SeasonInfo.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 59c7517a..b046a996 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -284,7 +284,7 @@ public bool IsEmpty(int offset = 0) } private static string? GetImagePath(Image image) - => image != null && image.IsAvailable ? image.ToURLString(internalUrl: true) : null; + => image != null && !string.IsNullOrEmpty(image.LocalPath) ? image.ToURLString(internalUrl: true) : null; private static PersonInfo? RoleToPersonInfo(Role role) => role.Type switch From 18849ae6af5d1adf92c3de1daebcfc5f3e8a0ac4 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 20 Aug 2024 00:54:54 +0200 Subject: [PATCH 034/144] fix: remove image language code again MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Removed the language code for images, since it was interfering with the _preferred_ image selection done by shoko. It seems Jellyfin has it's own image selection logic, and it doesn't just pick the first image available… it picks the first image available in the metadata language of the library… which defaults to `"en"` if not set… and that might not be preferred for our use case. We'll see if we can partially add it back later for some image types, e.g. logos, etc., but for now it's gone. --- Shokofin/Providers/ImageProvider.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index bac54cd6..42e95a60 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -163,7 +163,6 @@ private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType return; list.Add(new RemoteImageInfo { ProviderName = Plugin.MetadataProviderName, - Language = image.LanguageCode, Type = imageType, Width = image.Width, Height = image.Height, From 6700b77884b5063c53f7eea4c68e5cc5b89e260b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 22 Aug 2024 20:48:47 +0200 Subject: [PATCH 035/144] fix: sanitize release groups in vfs file names - Sanitized release groups (and resolution) before we use them. - Swapped release group so we first try the short name, then the full name. --- Shokofin/Resolvers/VirtualFileSystemService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index dd67d2d7..0c7993fe 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -767,16 +767,16 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (config.VFS_AddReleaseGroup) extraDetails.Add( file.Shoko.AniDBData is not null - ? !string.IsNullOrEmpty(file.Shoko.AniDBData.ReleaseGroup.Name) - ? file.Shoko.AniDBData.ReleaseGroup.Name - : !string.IsNullOrEmpty(file.Shoko.AniDBData.ReleaseGroup.ShortName) - ? file.Shoko.AniDBData.ReleaseGroup.ShortName + ? !string.IsNullOrEmpty(file.Shoko.AniDBData.ReleaseGroup.ShortName) + ? file.Shoko.AniDBData.ReleaseGroup.ShortName + : !string.IsNullOrEmpty(file.Shoko.AniDBData.ReleaseGroup.Name) + ? file.Shoko.AniDBData.ReleaseGroup.Name : $"Release group {file.Shoko.AniDBData.ReleaseGroup.Id}" : "No Group" ); if (config.VFS_AddResolution && !string.IsNullOrEmpty(file.Shoko.Resolution)) extraDetails.Add(file.Shoko.Resolution); - var fileName = $"{episodeName} {(extraDetails.Count is > 0 ? $"[{extraDetails.Join("] [")}] " : "")}[{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{Path.GetExtension(sourceLocation)}"; + var fileName = $"{episodeName} {(extraDetails.Count is > 0 ? $"[{extraDetails.Select(a => a.ReplaceInvalidPathCharacters()).Join("] [")}] " : "")}[{ShokoSeriesId.Name}={seriesId}] [{ShokoFileId.Name}={fileId}]{Path.GetExtension(sourceLocation)}"; var symbolicLinks = folders .Select(folderPath => Path.Join(folderPath, fileName)) .ToArray(); From bd8fe03b49ece04f167f1fe234626ce4ba67b9dd Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 24 Aug 2024 04:33:02 +0200 Subject: [PATCH 036/144] fix: attach VFS children to first available media folder - Attach VFS children to the first available media folder, instead of the first media folder in the list. If no media folders are available at all, then the VFS generation will be skipped (and Jellyfin will also internally skip the iteration of the children of the media folder if that were the case, so the shows/movies won't just disappear). --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index af4da12f..d5987303 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -7,6 +7,7 @@ using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; @@ -42,6 +43,8 @@ public class MediaFolderConfigurationService private readonly IFileSystem FileSystem; + private readonly IDirectoryService DirectoryService; + private readonly ShokoAPIClient ApiClient; private readonly NamingOptions NamingOptions; @@ -60,6 +63,7 @@ public MediaFolderConfigurationService( ILogger<MediaFolderConfigurationService> logger, ILibraryManager libraryManager, IFileSystem fileSystem, + IDirectoryService directoryService, ShokoAPIClient apiClient, NamingOptions namingOptions ) @@ -67,6 +71,7 @@ NamingOptions namingOptions Logger = logger; LibraryManager = libraryManager; FileSystem = fileSystem; + DirectoryService = directoryService; ApiClient = apiClient; NamingOptions = namingOptions; @@ -161,7 +166,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) return (string.Empty, string.Empty, null, new List<MediaFolderConfiguration>()); return ( libraryFolder.GetVirtualRoot(), - virtualFolder.Locations[0], + virtualFolder.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty, LibraryManager.GetConfiguredContentType(libraryFolder), Plugin.Instance.Configuration.MediaFolders .Where(config => config.IsMapped && config.LibraryId == mediaFolderConfig.LibraryId && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) From ec0d30d510c2aa05cbdb223db85fae09da57f832 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 24 Aug 2024 04:46:34 +0200 Subject: [PATCH 037/144] fix: also fix it for events - Same as previous commit, but for the the method that gets all available folders. --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index d5987303..84bf121c 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -149,7 +149,13 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) .ToList() as IReadOnlyList<MediaFolderConfiguration> )) .Where(tuple => tuple.libraryFolder is not null && tuple.virtualFolder is not null && tuple.virtualFolder.Locations.Length is > 0 && tuple.mediaList.Count is > 0) - .Select(tuple => (tuple.libraryFolder!.GetVirtualRoot(), tuple.virtualFolder!.Locations[0], LibraryManager.GetConfiguredContentType(tuple.libraryFolder!), tuple.mediaList)) + .Select(tuple => ( + vfsPath: tuple.libraryFolder!.GetVirtualRoot(), + mainMediaFolderPath: tuple.virtualFolder!.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty, + collectionType: LibraryManager.GetConfiguredContentType(tuple.libraryFolder!), + tuple.mediaList + )) + .Where(tuple => !string.IsNullOrEmpty(tuple.vfsPath) && !string.IsNullOrEmpty(tuple.mainMediaFolderPath)) .ToList(); } } From e140e19fb27776c9a792d4639bebd872605c9cb7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 24 Aug 2024 04:47:36 +0200 Subject: [PATCH 038/144] cleanup: remove empty line [skip ci] --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 1 - 1 file changed, 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 84bf121c..5b1909a2 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -135,7 +135,6 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) { - lock (LockObj) { var virtualFolders = LibraryManager.GetVirtualFolders(); return Plugin.Instance.Configuration.MediaFolders From e1f3757fa809b3e2dcc3f00656c50532d026fc52 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 4 Sep 2024 20:44:37 +0200 Subject: [PATCH 039/144] fix: stop fetching remote images --- Shokofin/API/Info/SeasonInfo.cs | 2 +- Shokofin/API/Models/Image.cs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index b046a996..59c7517a 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -284,7 +284,7 @@ public bool IsEmpty(int offset = 0) } private static string? GetImagePath(Image image) - => image != null && !string.IsNullOrEmpty(image.LocalPath) ? image.ToURLString(internalUrl: true) : null; + => image != null && image.IsAvailable ? image.ToURLString(internalUrl: true) : null; private static PersonInfo? RoleToPersonInfo(Role role) => role.Type switch diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index d72b2867..e01a84f8 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -63,7 +63,7 @@ public class Image /// </summary> [JsonIgnore] public virtual bool IsAvailable - => !string.IsNullOrEmpty(LocalPath) || (Plugin.Instance.Configuration.ServerVersion?.Version is {} version && version > new Version("4.2.2.0")); + => !string.IsNullOrEmpty(LocalPath); /// <summary> /// Get an URL to both download the image on the backend and preview it for From 55c7ce3dbd9f20a7370706ce46d785e1441dc558 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 8 Sep 2024 20:02:19 +0200 Subject: [PATCH 040/144] misc: add 'manners movie' as extras --- Shokofin/Utils/Ordering.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index 157c4b4a..eda47020 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -289,7 +289,8 @@ public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epis // Cinema/theatrical intro/outro if ( (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) || title.StartsWith("theatrical ", System.StringComparison.OrdinalIgnoreCase)) && - (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase)) + (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase)) || + title.Contains("manners movie", System.StringComparison.OrdinalIgnoreCase) ) return ExtraType.Clip; // Behind the Scenes From ea04250cbddc00d3a22e00e2bb23acc30dc4fb08 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 8 Sep 2024 20:26:06 +0200 Subject: [PATCH 041/144] misc: merge paths for special and other type episodes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit …because they should had probably been merged into one earlier… --- Shokofin/Utils/Ordering.cs | 16 +--------------- 1 file changed, 1 insertion(+), 15 deletions(-) diff --git a/Shokofin/Utils/Ordering.cs b/Shokofin/Utils/Ordering.cs index eda47020..a91697ac 100644 --- a/Shokofin/Utils/Ordering.cs +++ b/Shokofin/Utils/Ordering.cs @@ -264,21 +264,7 @@ public static int GetSeasonNumber(ShowInfo showInfo, SeasonInfo seasonInfo, Epis return ExtraType.ThemeVideo; case EpisodeType.Trailer: return ExtraType.Trailer; - case EpisodeType.Other: { - var title = Text.GetTitlesForLanguage(episode.Titles, false, "en"); - if (string.IsNullOrEmpty(title)) - return null; - // Interview - if (title.Contains("interview", System.StringComparison.OrdinalIgnoreCase)) - return ExtraType.Interview; - // Cinema/theatrical intro/outro - if ( - (title.StartsWith("cinema ", System.StringComparison.OrdinalIgnoreCase) || title.StartsWith("theatrical ", System.StringComparison.OrdinalIgnoreCase)) && - (title.Contains("intro", System.StringComparison.OrdinalIgnoreCase) || title.Contains("outro", System.StringComparison.OrdinalIgnoreCase)) - ) - return ExtraType.Clip; - return null; - } + case EpisodeType.Other: case EpisodeType.Special: { var title = Text.GetTitlesForLanguage(episode.Titles, false, "en"); if (string.IsNullOrEmpty(title)) From dd54dfe6a73735469c0fb3176c64659477052945 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 10 Sep 2024 02:31:16 +0200 Subject: [PATCH 042/144] refactor: always split & merge entries --- Shokofin/Configuration/PluginConfiguration.cs | 12 -- Shokofin/MergeVersions/MergeVersionManager.cs | 150 +++++------------- 2 files changed, 39 insertions(+), 123 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index a423d046..ddd7ebb3 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -443,16 +443,6 @@ public virtual string PrettyUrl /// </summary> public bool EXPERIMENTAL_AutoMergeVersions { get; set; } - /// <summary> - /// Split all movies up before merging them back together. - /// </summary> - public bool EXPERIMENTAL_SplitThenMergeMovies { get; set; } - - /// <summary> - /// Split all episodes up before merging them back together. - /// </summary> - public bool EXPERIMENTAL_SplitThenMergeEpisodes { get; set; } - /// <summary> /// Blur the boundaries between AniDB anime further by merging entries which could had just been a single anime entry based on name matching and a configurable merge window. /// </summary> @@ -571,8 +561,6 @@ public PluginConfiguration() SignalR_FileEvents = false; UsageTracker_StalledTimeInSeconds = 10; EXPERIMENTAL_AutoMergeVersions = true; - EXPERIMENTAL_SplitThenMergeMovies = true; - EXPERIMENTAL_SplitThenMergeEpisodes = false; EXPERIMENTAL_MergeSeasons = false; EXPERIMENTAL_MergeSeasonsTypes = new[] { SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA }; EXPERIMENTAL_MergeSeasonsMergeWindowInDays = 185; diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index d5e147a6..bbd4dbd8 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -147,23 +147,31 @@ public static async Task MergeMovies(IEnumerable<Movie> movies) /// complete.</returns> public async Task MergeAllMovies(IProgress<double> progress, CancellationToken cancellationToken) { - if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeMovies) { - await SplitAndMergeAllMovies(progress, cancellationToken); - return; + // Split up any existing merged movies. + var movies = GetMoviesFromLibrary(); + double currentCount = 0d; + double totalCount = movies.Count; + foreach (var movie in movies) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalCount) * 50d; + progress?.Report(percent); + + // Remove all alternate sources linked to the movie. + await RemoveAlternateSources(movie); } - // Merge all movies with more than one version. - var movies = GetMoviesFromLibrary(); + // Merge all movies with more than one version (again). var duplicationGroups = movies - .GroupBy(x => (x.GetTopParent()?.Path, x.GetProviderId(ShokoEpisodeId.Name))) - .Where(x => x.Count() > 1) + .GroupBy(movie => (movie.GetTopParent()?.Path, movie.GetProviderId(ShokoEpisodeId.Name))) + .Where(movie => movie.Count() > 1) .ToList(); - double currentCount = 0d; - double totalGroups = duplicationGroups.Count; + currentCount = 0d; + totalCount = duplicationGroups.Count; foreach (var movieGroup in duplicationGroups) { // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalGroups) * 100; + var percent = 50d + ((currentCount++ / totalCount) * 50d); progress?.Report(percent); // Link the movies together as alternate sources. @@ -199,49 +207,6 @@ public async Task SplitAllMovies(IProgress<double> progress, CancellationToken c progress?.Report(100); } - /// <summary> - /// - /// </summary> - /// <param name="progress">Progress indicator.</param> - /// <param name="cancellationToken">Cancellation token.</param> - /// <returns>An async task that will silently complete when the splitting - /// followed by merging is complete.</returns> - private async Task SplitAndMergeAllMovies(IProgress<double> progress, CancellationToken cancellationToken) - { - // Split up any existing merged movies. - var movies = GetMoviesFromLibrary(); - double currentCount = 0d; - double totalCount = movies.Count; - foreach (var movie in movies) { - // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalCount) * 50d; - progress?.Report(percent); - - // Remove all alternate sources linked to the movie. - await RemoveAlternateSources(movie); - } - - // Merge all movies with more than one version (again). - var duplicationGroups = movies - .GroupBy(movie => (movie.GetTopParent()?.Path, movie.GetProviderId(ShokoEpisodeId.Name))) - .Where(movie => movie.Count() > 1) - .ToList(); - currentCount = 0d; - totalCount = duplicationGroups.Count; - foreach (var movieGroup in duplicationGroups) { - // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = 50d + ((currentCount++ / totalCount) * 50d); - progress?.Report(percent); - - // Link the movies together as alternate sources. - await MergeMovies(movieGroup); - } - - progress?.Report(100); - } - #endregion Movies #region Episodes @@ -272,40 +237,46 @@ public static async Task MergeEpisodes(IEnumerable<Episode> episodes) => await MergeVideos(episodes.Cast<Video>().OrderBy(e => e.Id).ToList()); /// <summary> - /// Split up all existing merged versions of each movie and merge them - /// again afterwards. Only applied to movies with a Shoko Episode ID set. + /// Split up all existing merged versions of each episode and merge them + /// again afterwards. Only applied to episodes with a Shoko Episode ID set. /// </summary> /// <param name="progress">Progress indicator.</param> /// <param name="cancellationToken">Cancellation token.</param> - /// <returns>An async task that will silently complete when the merging is - /// complete.</returns> + /// <returns>An async task that will silently complete when the splitting + /// followed by merging is complete.</returns> public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) { - if (Plugin.Instance.Configuration.EXPERIMENTAL_SplitThenMergeEpisodes) { - await SplitAndMergeAllEpisodes(progress, cancellationToken); - return; + // Split up any existing merged episodes. + var episodes = GetEpisodesFromLibrary(); + double currentCount = 0d; + double totalCount = episodes.Count; + foreach (var e in episodes) { + // Handle cancellation and update progress. + cancellationToken.ThrowIfCancellationRequested(); + var percent = (currentCount++ / totalCount) * 100d; + progress?.Report(percent); + + // Remove all alternate sources linked to the episode. + await RemoveAlternateSources(e); } - // Merge episodes with more than one version, and with the same number - // of additional episodes. - var episodes = GetEpisodesFromLibrary(); + // Merge episodes with more than one version (again), and with the same + // number of additional episodes. var duplicationGroups = episodes .GroupBy(e => (e.GetTopParent()?.Path, $"{e.GetProviderId(ShokoEpisodeId.Name)}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}")) .Where(e => e.Count() > 1) .ToList(); - double currentCount = 0d; - double totalGroups = duplicationGroups.Count; + currentCount = 0d; + totalCount = duplicationGroups.Count; foreach (var episodeGroup in duplicationGroups) { // Handle cancellation and update progress. cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalGroups) * 100d; + var percent = currentCount++ / totalCount * 100d; progress?.Report(percent); // Link the episodes together as alternate sources. await MergeEpisodes(episodeGroup); } - - progress?.Report(100); } /// <summary> @@ -334,49 +305,6 @@ public async Task SplitAllEpisodes(IProgress<double> progress, CancellationToken progress?.Report(100); } - /// <summary> - /// Split up all existing merged versions of each episode and merge them - /// again afterwards. Only applied to episodes with a Shoko Episode ID set. - /// </summary> - /// <param name="progress">Progress indicator.</param> - /// <param name="cancellationToken">Cancellation token.</param> - /// <returns>An async task that will silently complete when the splitting - /// followed by merging is complete.</returns> - private async Task SplitAndMergeAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) - { - // Split up any existing merged episodes. - var episodes = GetEpisodesFromLibrary(); - double currentCount = 0d; - double totalCount = episodes.Count; - foreach (var e in episodes) { - // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalCount) * 100d; - progress?.Report(percent); - - // Remove all alternate sources linked to the episode. - await RemoveAlternateSources(e); - } - - // Merge episodes with more than one version (again), and with the same - // number of additional episodes. - var duplicationGroups = episodes - .GroupBy(e => (e.GetTopParent()?.Path, $"{e.GetProviderId(ShokoEpisodeId.Name)}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}")) - .Where(e => e.Count() > 1) - .ToList(); - currentCount = 0d; - totalCount = duplicationGroups.Count; - foreach (var episodeGroup in duplicationGroups) { - // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = currentCount++ / totalCount * 100d; - progress?.Report(percent); - - // Link the episodes together as alternate sources. - await MergeEpisodes(episodeGroup); - } - } - #endregion Episodes /// <summary> From 20acd6b717311472ec78fe93e0eada9947adf126 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 10 Sep 2024 02:31:55 +0200 Subject: [PATCH 043/144] misc: add files per commit for git-log-json.mjs --- .github/workflows/git-log-json.mjs | 38 +++++++++++++++++++++++++++++- 1 file changed, 37 insertions(+), 1 deletion(-) diff --git a/.github/workflows/git-log-json.mjs b/.github/workflows/git-log-json.mjs index 40b9916c..ae305e80 100755 --- a/.github/workflows/git-log-json.mjs +++ b/.github/workflows/git-log-json.mjs @@ -69,6 +69,41 @@ for (const [placeholder, name] of Object.entries(Placeholders)) { } } +// Add file-level changes to each commit +for (const commitId of commitOrder) { + const fileStatusOutput = execSync(`git diff --name-status ${commitId}^ ${commitId}`).toString(); + const lineChangesOutput = execSync(`git diff --numstat ${commitId}^ ${commitId}`).toString(); + + const files = []; + const fileStatusLines = fileStatusOutput.split(/\r\n|\r|\n/g).filter(a => a); + const lineChangesLines = lineChangesOutput.split(/\r\n|\r|\n/g).filter(a => a); + + for (const [index, line] of fileStatusLines.entries()) { + const [rawStatus, path] = line.split(/\t/); + const status = rawStatus === "M" ? + "modified" + : rawStatus === "A" ? + "added" + : rawStatus === "D" ? + "deleted" + : rawStatus === "R" ? + "renamed" + : "untracked"; + const lineChangeParts = lineChangesLines[index].split(/\t/); + const addedLines = parseInt(lineChangeParts[0] || "0", 10); + const removedLines = parseInt(lineChangeParts[1] || "0", 10); + + files.push({ + path, + status, + addedLines, + removedLines, + }); + } + + commits[commitId].files = files; +} + // Trim trailing newlines from all values in the commits object for (const commit of Object.values(commits)) { for (const key in commit) { @@ -81,7 +116,7 @@ for (const commit of Object.values(commits)) { // Convert commits object to a list of values const commitsList = commitOrder.reverse() .map((commitId) => commits[commitId]) - .map(({ commit, parents, tree, subject, body, author_name, author_email, author_date, committer_name, committer_email, committer_date }) => ({ + .map(({ commit, parents, tree, subject, body, author_name, author_email, author_date, committer_name, committer_email, committer_date, files }) => ({ commit, parents, tree, @@ -108,6 +143,7 @@ const commitsList = commitOrder.reverse() date: new Date(committer_date).toISOString(), timeZone: committer_date.substring(19) === "Z" ? "+00:00" : committer_date.substring(19), }, + files, })) .map((commit) => ({ ...commit, From 1b37ccc5766c0ad209b5623c0ff271fd6dce99f9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 10 Sep 2024 02:39:09 +0200 Subject: [PATCH 044/144] misc: mark chronological season ordering as experimental (again) --- Shokofin/Configuration/configPage.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 235db113..933a284e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1127,8 +1127,8 @@ <h3>Library Settings</h3> <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> <option value="Default" selected>Let Shoko decide</option> <option value="ReleaseDate">Order seasons by release date</option> - <option value="Chronological">Order seasons in chronological order (use indirect relations)</option> - <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations)</option> + <option value="Chronological">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> + <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> </select> <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> </div> From cad46bbc160214e669365fffcf9dae13b4fc2f62 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 11 Sep 2024 02:40:19 +0200 Subject: [PATCH 045/144] refactor: promote auto merge version from the experimental section - Promote the auto-merge multiple versions setting from the experimental section to the library section. The name is kept in the xml settings for now so people won't have to manually re-enable it. - Remove the split & merge options from the settings UI, since I forgot when I removed them from the code earlier. --- Shokofin/Configuration/PluginConfiguration.cs | 13 +++++---- Shokofin/Configuration/configController.js | 13 ++------- Shokofin/Configuration/configPage.html | 28 +++++-------------- Shokofin/Tasks/PostScanTask.cs | 2 +- 4 files changed, 18 insertions(+), 38 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index ddd7ebb3..882321f2 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -276,6 +276,12 @@ public virtual string PrettyUrl #region Library + /// <summary> + /// Automagically merge alternate versions after a library scan. + /// </summary> + [XmlElement("EXPERIMENTAL_AutoMergeVersions")] + public bool AutoMergeVersions { get; set; } + /// <summary> /// Use Shoko Groups to group Shoko Series together to create the show entries. /// </summary> @@ -438,11 +444,6 @@ public virtual string PrettyUrl #region Experimental features - /// <summary> - /// Automagically merge alternate versions after a library scan. - /// </summary> - public bool EXPERIMENTAL_AutoMergeVersions { get; set; } - /// <summary> /// Blur the boundaries between AniDB anime further by merging entries which could had just been a single anime entry based on name matching and a configurable merge window. /// </summary> @@ -536,6 +537,7 @@ public PluginConfiguration() VFS_Threads = 4; VFS_AddReleaseGroup = false; VFS_AddResolution = false; + AutoMergeVersions = true; UseGroupsForShows = false; SeparateMovies = false; FilterMovieLibraries = true; @@ -560,7 +562,6 @@ public PluginConfiguration() SignalR_RefreshEnabled = false; SignalR_FileEvents = false; UsageTracker_StalledTimeInSeconds = 10; - EXPERIMENTAL_AutoMergeVersions = true; EXPERIMENTAL_MergeSeasons = false; EXPERIMENTAL_MergeSeasonsTypes = new[] { SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA }; EXPERIMENTAL_MergeSeasonsMergeWindowInDays = 185; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index d4dd42b4..45e250e1 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -306,6 +306,7 @@ async function defaultSubmit(form) { config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings + config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; @@ -360,9 +361,6 @@ async function defaultSubmit(form) { // Experimental settings - config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; - config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; - config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; // User settings @@ -518,6 +516,7 @@ async function syncSettings(form) { config.AddTMDBId = form.querySelector("#AddTMDBId").checked; // Library settings + config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; @@ -532,9 +531,6 @@ async function syncSettings(form) { config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Experimental settings - config.EXPERIMENTAL_AutoMergeVersions = form.querySelector("#EXPERIMENTAL_AutoMergeVersions").checked; - config.EXPERIMENTAL_SplitThenMergeMovies = form.querySelector("#EXPERIMENTAL_SplitThenMergeMovies").checked; - config.EXPERIMENTAL_SplitThenMergeEpisodes = form.querySelector("#EXPERIMENTAL_SplitThenMergeEpisodes").checked; config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); @@ -982,6 +978,7 @@ export default function (page) { form.querySelector("#AddTMDBId").checked = config.AddTMDBId; // Library settings + form.querySelector("#AutoMergeVersions").checked = config.AutoMergeVersions || false; if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows) { form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); form.querySelector("#SeasonOrdering").disabled = false; @@ -1027,10 +1024,6 @@ export default function (page) { userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).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_SplitThenMergeEpisodes").checked = config.EXPERIMENTAL_SplitThenMergeEpisodes || false; form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; if (!config.ApiKey) { diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 933a284e..0bad911c 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1095,6 +1095,13 @@ <h3>Library Settings</h3> <div class="fieldDescription verticalSection-extrabottompadding"> Placeholder description. </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AutoMergeVersions" /> + <span>Automatically merge multiple versions of videos</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Automatically merge multiple versions of the same video together after a library scan or refresh. Only applies to videos with Shoko IDs set.</div></div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="UseGroupsForShows" /> @@ -1575,27 +1582,6 @@ <h3>User Settings</h3> <h3>Experimental Settings</h3> </legend> <div class="fieldDescription verticalSection-extrabottompadding">Any features/settings in this section is still considered to be in an experimental state. <strong>You can enable them, but at the risk if them messing up your library.</strong></div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_AutoMergeVersions" /> - <span>Automatically merge multiple versions</span> - </label> - <div class="fieldDescription checkboxFieldDescription"><div>Automatically merge multiple versions of the same item together after a library scan. Only applies to items with a Shoko ID set.</div></div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_SplitThenMergeMovies" /> - <span>Always split existing versions of movies before merging multiple versions</span> - </label> - <div class="fieldDescription checkboxFieldDescription"><div>Always split existing merged items before starting the merge.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will make sure there will be no merge conflicts by splitting up all the existing merged versions. It will probably also make the plugin do a lot of unneeded work 99.5% of the time, but will help for the last 0.5% that actually need this to be done before every merge. An example is ensuring multiple parts of a movie series (e.g. "Part 1", "Part 2", etc.) are not merged in core Jellyfin because they contain the same Shoko Series Id.</details></div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_SplitThenMergeEpisodes" /> - <span>Always split existing versions of episodes before merging multiple versions</span> - </label> - <div class="fieldDescription checkboxFieldDescription"><div>Always split existing merged items before starting the merge.</div><details style="margin-top: 0.5em"><summary style="margin-bottom: 0.25em">What is this setting for?</summary>Enabling this setting will make sure there will be no merge conflicts by splitting up all the existing merged versions. It will probably also make the plugin do a lot of unneeded work 99.5% of the time, but will help for the last 0.5% that actually need this to be done before every merge.</details></div> - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index caa668b2..2112649a 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -24,7 +24,7 @@ public PostScanTask(MergeVersionsManager versionsManager, CollectionManager coll public async Task Run(IProgress<double> progress, CancellationToken token) { // Merge versions now if the setting is enabled. - if (Plugin.Instance.Configuration.EXPERIMENTAL_AutoMergeVersions) { + if (Plugin.Instance.Configuration.AutoMergeVersions) { // Setup basic progress tracking var baseProgress = 0d; var simpleProgress = new Progress<double>(value => progress.Report(baseProgress + (value / 2d))); From 32ea3fe75f87a29ca975eccf7e1be5691750c16c Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Sat, 14 Sep 2024 16:42:32 +0200 Subject: [PATCH 046/144] misc: log which key is throwing from the guarded memory cache --- Shokofin/Utils/GuardedMemoryCache.cs | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/Shokofin/Utils/GuardedMemoryCache.cs b/Shokofin/Utils/GuardedMemoryCache.cs index b6d00352..93d4d56d 100644 --- a/Shokofin/Utils/GuardedMemoryCache.cs +++ b/Shokofin/Utils/GuardedMemoryCache.cs @@ -80,6 +80,10 @@ public TItem GetOrCreate<TItem>(object key, Action<TItem> foundAction, Func<TIte throw; } + catch (Exception ex) { + Logger.LogWarning(ex, "Got an unexpected exception for key: {Key}", key); + throw; + } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> foundAction, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) @@ -122,6 +126,10 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Action<TItem> found throw; } + catch (Exception ex) { + Logger.LogWarning(ex, "Got an unexpected exception for key: {Key}", key); + throw; + } } public TItem GetOrCreate<TItem>(object key, Func<TItem> createFactory, MemoryCacheEntryOptions? createOptions = null) @@ -159,6 +167,10 @@ public TItem GetOrCreate<TItem>(object key, Func<TItem> createFactory, MemoryCac throw; } + catch (Exception ex) { + Logger.LogWarning(ex, "Got an unexpected exception for key: {Key}", key); + throw; + } } public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<Task<TItem>> createFactory, MemoryCacheEntryOptions? createOptions = null) @@ -196,6 +208,10 @@ public async Task<TItem> GetOrCreateAsync<TItem>(object key, Func<Task<TItem>> c throw; } + catch (Exception ex) { + Logger.LogWarning(ex, "Got an unexpected exception for key: {Key}", key); + throw; + } } public void Dispose() @@ -218,4 +234,4 @@ public bool TryGetValue<TItem>(object key, [NotNullWhen(true)] out TItem? value) public TItem? Set<TItem>(object key, [NotNullIfNotNull(nameof(value))] TItem? value, MemoryCacheEntryOptions? createOptions = null) => Cache.Set(key, value, createOptions ?? CacheEntryOptions); -} \ No newline at end of file +} From 46621a53d1c2444f6b4dabdc37d940e2bc38a221 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 11 Sep 2024 04:13:49 +0200 Subject: [PATCH 047/144] cleanup: remove unneeded setting --- Shokofin/Configuration/PluginConfiguration.cs | 7 ------- Shokofin/Configuration/configController.js | 4 ---- Shokofin/Configuration/configPage.html | 7 ------- Shokofin/Providers/EpisodeProvider.cs | 2 +- 4 files changed, 1 insertion(+), 19 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 882321f2..03f61a93 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -120,12 +120,6 @@ public virtual string PrettyUrl /// </summary> public bool TitleAllowAny { get; set; } - /// <summary> - /// This will combine the titles for multi episodes entries into a single - /// title, instead of just showing the title for the first episode. - /// </summary> - public bool TitleAddForMultipleEpisodes { get; set; } - /// <summary> /// Mark any episode that is not considered a normal season episode with a /// prefix and number. @@ -496,7 +490,6 @@ public PluginConfiguration() ProviderName.TMDB, }; ProductionLocationOrder = ProductionLocationList.ToArray(); - TitleAddForMultipleEpisodes = true; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 45e250e1..90a38b4f 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -277,7 +277,6 @@ async function defaultSubmit(form) { 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; config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); @@ -487,7 +486,6 @@ async function syncSettings(form) { 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; config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableList(form, "DescriptionSourceList")); @@ -900,8 +898,6 @@ export default function (page) { } 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("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; if (form.querySelector("#DescriptionSourceOverride").checked = config.DescriptionSourceOverride) { form.querySelector("#DescriptionSourceList").removeAttribute("hidden"); diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0bad911c..5375409d 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -58,13 +58,6 @@ <h3>Metadata Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="TitleAddForMultipleEpisodes" /> - <span>Add all metadata for multi-episode entries</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Will add the title and description for every episode in a multi-episode entry.</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleAllowAny" /> diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index c0d33afa..52188074 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -104,7 +104,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie { var config = Plugin.Instance.Configuration; string? displayTitle, alternateTitle, description; - if (config.TitleAddForMultipleEpisodes && file != null && file.EpisodeList.Count > 1) { + if (file != null && file.EpisodeList.Count > 1) { var displayTitles = new List<string?>(); var alternateTitles = new List<string?>(); foreach (var (episodeInfo, _, _) in file.EpisodeList) { From d550585e435b862f043b5149e085f1cbbf4d029e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 11 Sep 2024 04:28:32 +0200 Subject: [PATCH 048/144] misc: disable any title by default --- Shokofin/Configuration/PluginConfiguration.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 03f61a93..d3eba747 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -514,7 +514,7 @@ public PluginConfiguration() TitleProvider.AniDB_CountryOfOrigin }; TitleAlternateOrder = TitleMainOrder.ToArray(); - TitleAllowAny = true; + TitleAllowAny = false; DescriptionSourceOverride = false; DescriptionSourceList = new[] { DescriptionProvider.AniDB, From c9ebc96a2612c1fa2dd890a3266dce8ae3c572ac Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 01:42:50 +0200 Subject: [PATCH 049/144] fix: enable schedule tasks - Enable the schedule tasks so they are allowed to be ran on a schedule. --- Shokofin/Tasks/CleanupVirtualRootTask.cs | 2 +- Shokofin/Tasks/ClearPluginCacheTask.cs | 2 +- Shokofin/Tasks/ExportUserDataTask.cs | 2 +- Shokofin/Tasks/ImportUserDataTask.cs | 2 +- Shokofin/Tasks/MergeEpisodesTask.cs | 2 +- Shokofin/Tasks/MergeMoviesTask.cs | 2 +- Shokofin/Tasks/MigrateEpisodeUserDataTask.cs | 2 +- Shokofin/Tasks/ReconstructCollectionsTask.cs | 2 +- Shokofin/Tasks/SplitEpisodesTask.cs | 2 +- Shokofin/Tasks/SplitMoviesTask.cs | 2 +- Shokofin/Tasks/SyncUserDataTask.cs | 2 +- 11 files changed, 11 insertions(+), 11 deletions(-) diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index 6cf4f437..f5de8257 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -32,7 +32,7 @@ public class CleanupVirtualRootTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs index dd8655e1..c67399f5 100644 --- a/Shokofin/Tasks/ClearPluginCacheTask.cs +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -29,7 +29,7 @@ public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs index 24972b0c..8bd16c56 100644 --- a/Shokofin/Tasks/ExportUserDataTask.cs +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -25,7 +25,7 @@ public class ExportUserDataTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs index 8c8095f2..aec36544 100644 --- a/Shokofin/Tasks/ImportUserDataTask.cs +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -25,7 +25,7 @@ public class ImportUserDataTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index dbabfc9b..80fa6298 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -26,7 +26,7 @@ public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index 8fbbed23..fbeff678 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -26,7 +26,7 @@ public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs b/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs index 0fac08ab..ce0b348c 100644 --- a/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs +++ b/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs @@ -35,7 +35,7 @@ public class MigrateEpisodeUserDataTask : IScheduledTask, IConfigurableScheduled public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/ReconstructCollectionsTask.cs b/Shokofin/Tasks/ReconstructCollectionsTask.cs index c641e29e..1b372802 100644 --- a/Shokofin/Tasks/ReconstructCollectionsTask.cs +++ b/Shokofin/Tasks/ReconstructCollectionsTask.cs @@ -29,7 +29,7 @@ public class ReconstructCollectionsTask : IScheduledTask, IConfigurableScheduled public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs index fe87c518..ceb4dd83 100644 --- a/Shokofin/Tasks/SplitEpisodesTask.cs +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -27,7 +27,7 @@ public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs index d7edc722..1aa82b77 100644 --- a/Shokofin/Tasks/SplitMoviesTask.cs +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -29,7 +29,7 @@ public class SplitMoviesTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index 1fb7e5ce..ebc0cd06 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -25,7 +25,7 @@ public class SyncUserDataTask : IScheduledTask, IConfigurableScheduledTask public bool IsHidden => false; /// <inheritdoc /> - public bool IsEnabled => false; + public bool IsEnabled => true; /// <inheritdoc /> public bool IsLogged => true; From 87466984902f81e6d77252a413727dd31ef81579 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 02:29:22 +0200 Subject: [PATCH 050/144] chore: modernize array/list syntax usage --- Shokofin/API/Info/FileInfo.cs | 4 +- Shokofin/API/Info/SeasonInfo.cs | 6 +- Shokofin/API/Info/ShowInfo.cs | 2 +- Shokofin/API/Models/ApiException.cs | 10 ++-- Shokofin/API/Models/CrossReference.cs | 2 +- Shokofin/API/Models/Episode.cs | 8 +-- Shokofin/API/Models/File.cs | 4 +- Shokofin/API/Models/ListResult.cs | 2 +- Shokofin/API/Models/Tag.cs | 6 +- Shokofin/API/ShokoAPIManager.cs | 10 ++-- Shokofin/Collections/CollectionManager.cs | 16 ++--- .../Configuration/MediaFolderConfiguration.cs | 2 +- .../MediaFolderConfigurationService.cs | 6 +- ...ediaFolderConfigurationChangedEventArgs.cs | 1 - Shokofin/Configuration/PluginConfiguration.cs | 47 ++++++++------- Shokofin/Events/EventDispatchService.cs | 18 +++--- Shokofin/IdLookup.cs | 4 +- Shokofin/MergeVersions/MergeVersionManager.cs | 10 ++-- Shokofin/Plugin.cs | 6 +- Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Providers/CustomBoxSetProvider.cs | 2 +- Shokofin/Providers/CustomEpisodeProvider.cs | 6 +- Shokofin/Providers/CustomSeasonProvider.cs | 8 +-- Shokofin/Providers/CustomSeriesProvider.cs | 2 +- Shokofin/Providers/EpisodeProvider.cs | 2 +- Shokofin/Providers/ImageProvider.cs | 2 +- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 2 +- Shokofin/Providers/TrailerProvider.cs | 2 +- Shokofin/Providers/VideoProvider.cs | 2 +- .../Resolvers/Models/LinkGenerationResult.cs | 2 +- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 2 +- Shokofin/Resolvers/ShokoResolver.cs | 8 +-- .../Resolvers/VirtualFileSystemService.cs | 2 +- .../Models/EpisodeInfoUpdatedEventArgs.cs | 6 +- Shokofin/SignalR/Models/FileEventArgs.cs | 2 +- Shokofin/SignalR/Models/FileMovedEventArgs.cs | 2 +- .../SignalR/Models/FileRenamedEventArgs.cs | 2 +- .../Models/SeriesInfoUpdatedEventArgs.cs | 6 +- Shokofin/Sync/UserDataSyncManager.cs | 4 +- Shokofin/Utils/ContentRating.cs | 12 ++-- Shokofin/Utils/TagFilter.cs | 58 +++++++++---------- Shokofin/Utils/Text.cs | 20 +++---- 44 files changed, 161 insertions(+), 163 deletions(-) diff --git a/Shokofin/API/Info/FileInfo.cs b/Shokofin/API/Info/FileInfo.cs index 012bc516..105d958a 100644 --- a/Shokofin/API/Info/FileInfo.cs +++ b/Shokofin/API/Info/FileInfo.cs @@ -20,8 +20,8 @@ public class FileInfo public FileInfo(File file, List<List<(EpisodeInfo Episode, CrossReference.EpisodeCrossReferenceIDs CrossReference, string Id)>> groupedEpisodeLists, string seriesId) { - var episodeList = groupedEpisodeLists.FirstOrDefault() ?? new(); - var alternateEpisodeLists = groupedEpisodeLists.Count > 1 ? groupedEpisodeLists.GetRange(1, groupedEpisodeLists.Count - 1) : new(); + var episodeList = groupedEpisodeLists.FirstOrDefault() ?? []; + var alternateEpisodeLists = groupedEpisodeLists.Count > 1 ? groupedEpisodeLists.GetRange(1, groupedEpisodeLists.Count - 1) : []; Id = file.Id.ToString(); SeriesId = seriesId; ExtraType = episodeList.FirstOrDefault(tuple => tuple.Episode.ExtraType != null).Episode?.ExtraType; diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 59c7517a..39f073d6 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -198,7 +198,7 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext type = SeriesType.Web; episodesList = altEpisodesList; - altEpisodesList = new(); + altEpisodesList = []; // Re-create the special anchors because the episode list changed. index = 0; @@ -233,11 +233,11 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext if (specialsList.Count > 0) { extrasList.AddRange(specialsList); specialsAnchorDictionary.Clear(); - specialsList = new(); + specialsList = []; } if (altEpisodesList.Count > 0) { extrasList.AddRange(altEpisodesList); - altEpisodesList = new(); + altEpisodesList = []; } } diff --git a/Shokofin/API/Info/ShowInfo.cs b/Shokofin/API/Info/ShowInfo.cs index 4806f3dd..847009f3 100644 --- a/Shokofin/API/Info/ShowInfo.cs +++ b/Shokofin/API/Info/ShowInfo.cs @@ -182,7 +182,7 @@ public ShowInfo(SeasonInfo seasonInfo, string? collectionId = null) ProductionLocations = seasonInfo.ProductionLocations; Studios = seasonInfo.Studios; Staff = seasonInfo.Staff; - SeasonList = new List<SeasonInfo>() { seasonInfo }; + SeasonList = [seasonInfo]; SeasonNumberBaseDictionary = seasonNumberBaseDictionary; SeasonOrderDictionary = seasonOrderDictionary; SpecialsDict = seasonInfo.SpecialsList.ToDictionary(episodeInfo => episodeInfo.Id, episodeInfo => episodeInfo.Shoko.Size > 0); diff --git a/Shokofin/API/Models/ApiException.cs b/Shokofin/API/Models/ApiException.cs index 34e229fe..4c705ef5 100644 --- a/Shokofin/API/Models/ApiException.cs +++ b/Shokofin/API/Models/ApiException.cs @@ -14,7 +14,7 @@ public class ApiException : Exception private record ValidationResponse { - public Dictionary<string, string[]> errors = new(); + public Dictionary<string, string[]> errors = []; public string title = string.Empty; @@ -33,7 +33,7 @@ public ApiException(HttpStatusCode statusCode, string source, string? message) : { StatusCode = statusCode; Type = ApiExceptionType.Simple; - ValidationErrors = new(); + ValidationErrors = []; } protected ApiException(HttpStatusCode statusCode, RemoteApiException inner) : base(inner.Message, inner) @@ -41,14 +41,14 @@ protected ApiException(HttpStatusCode statusCode, RemoteApiException inner) : ba StatusCode = statusCode; Type = ApiExceptionType.RemoteException; Inner = inner; - ValidationErrors = new(); + ValidationErrors = []; } protected ApiException(HttpStatusCode statusCode, string source, string? message, Dictionary<string, string[]>? validationErrors = null): base(string.IsNullOrEmpty(message) ? source : $"{source}: {message}") { StatusCode = statusCode; Type = ApiExceptionType.ValidationErrors; - ValidationErrors = validationErrors ?? new(); + ValidationErrors = validationErrors ?? []; } public static ApiException FromResponse(HttpResponseMessage response) @@ -63,7 +63,7 @@ public static ApiException FromResponse(HttpResponseMessage response) var index = text.IndexOf("HEADERS"); if (index != -1) { var (firstLine, lines) = text[..index].TrimEnd().Split('\n'); - var (name, splitMessage) = firstLine?.Split(':') ?? Array.Empty<string>(); + var (name, splitMessage) = firstLine?.Split(':') ?? []; var message = string.Join(':', splitMessage).Trim(); var stackTrace = string.Join('\n', lines); return new ApiException(response.StatusCode, new RemoteApiException(name ?? "InternalServerException", message, stackTrace)); diff --git a/Shokofin/API/Models/CrossReference.cs b/Shokofin/API/Models/CrossReference.cs index babc6bf4..244bea8b 100644 --- a/Shokofin/API/Models/CrossReference.cs +++ b/Shokofin/API/Models/CrossReference.cs @@ -15,7 +15,7 @@ public class CrossReference /// The Episode IDs /// </summary> [JsonPropertyName("EpisodeIDs")] - public List<EpisodeCrossReferenceIDs> Episodes { get; set; } = new(); + public List<EpisodeCrossReferenceIDs> Episodes { get; set; } = []; /// <summary> /// File episode cross-reference for a series. diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index ae4d81b2..c37f4c51 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -42,12 +42,12 @@ public class Episode /// is included in the data to add. /// </summary> [JsonPropertyName("TvDB")] - public List<TvDB> TvDBEntityList { get; set; } = new(); + public List<TvDB> TvDBEntityList { get; set; } = []; /// <summary> /// File cross-references for the episode. /// </summary> - public List<CrossReference.EpisodeCrossReferenceIDs> CrossReferences { get; set; } = new(); + public List<CrossReference.EpisodeCrossReferenceIDs> CrossReferences { get; set; } = []; public class AniDB { @@ -66,7 +66,7 @@ public class AniDB public DateTime? AirDate { get; set; } - public List<Title> Titles { get; set; } = new(); + public List<Title> Titles { get; set; } = []; public string Description { get; set; } = string.Empty; @@ -98,7 +98,7 @@ public class EpisodeIDs : IDs public int AniDB { get; set; } - public List<int> TvDB { get; set; } = new(); + public List<int> TvDB { get; set; } = []; } } diff --git a/Shokofin/API/Models/File.cs b/Shokofin/API/Models/File.cs index ac90f9fd..5fa77329 100644 --- a/Shokofin/API/Models/File.cs +++ b/Shokofin/API/Models/File.cs @@ -18,7 +18,7 @@ public class File /// shown. In many cases, this will have arrays of 1 item /// </summary> [JsonPropertyName("SeriesIDs")] - public List<CrossReference> CrossReferences { get; set; } = new(); + public List<CrossReference> CrossReferences { get; set; } = []; /// <summary> /// The calculated hashes from the <see cref="File"/>. @@ -30,7 +30,7 @@ public class File /// <summary> /// All the <see cref="Location"/>s this <see cref="File"/> is present at. /// </summary> - public List<Location> Locations { get; set; } = new(); + public List<Location> Locations { get; set; } = []; /// <summary> /// Try to fit this file's resolution to something like 1080p, 480p, etc. diff --git a/Shokofin/API/Models/ListResult.cs b/Shokofin/API/Models/ListResult.cs index 64e44940..3529df44 100644 --- a/Shokofin/API/Models/ListResult.cs +++ b/Shokofin/API/Models/ListResult.cs @@ -19,5 +19,5 @@ public class ListResult<T> /// <summary> /// A sliced page or the whole list of <typeparamref name="T"/> entries. /// </summary> - public IReadOnlyList<T> List { get; set; } = new T[] {}; + public IReadOnlyList<T> List { get; set; } = []; } diff --git a/Shokofin/API/Models/Tag.cs b/Shokofin/API/Models/Tag.cs index a4b61fef..33a83f95 100644 --- a/Shokofin/API/Models/Tag.cs +++ b/Shokofin/API/Models/Tag.cs @@ -72,7 +72,7 @@ public class Tag public class ResolvedTag : Tag { // All the abstract tags I know about. - private static readonly HashSet<string> AbstractTags = new() { + private static readonly HashSet<string> AbstractTags = [ "/content indicators", "/dynamic", "/dynamic/cast", @@ -113,7 +113,7 @@ public class ResolvedTag : Tag "/unsorted/character related tags which need deleting or merging", "/unsorted/ending tags that need merging", "/unsorted/old animetags", - }; + ]; private static readonly Dictionary<string, string> TagNameOverrides = new() { { "/fetishes/housewives", "MILF" }, @@ -204,7 +204,7 @@ public ResolvedTag(Tag tag, ResolvedTag? parent, Func<string, int, IEnumerable<T LastUpdated = tag.LastUpdated; Source = tag.Source; Namespace = TagNamespaceOverride.TryGetValue(ns + "/" + tag.Name, out var newNs) ? newNs : ns; - Children = (getChildren(Source, Id) ?? Array.Empty<Tag>()) + Children = (getChildren(Source, Id) ?? []) .DistinctBy(childTag => childTag.Name) .Select(childTag => new ResolvedTag(childTag, this, getChildren, FullName + "/")) .ToDictionary(childTag => childTag.Name); diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 53047b08..776c06f9 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -30,7 +30,7 @@ public class ShokoAPIManager : IDisposable private readonly object MediaFolderListLock = new(); - private readonly List<Folder> MediaFolderList = new(); + private readonly List<Folder> MediaFolderList = []; private readonly ConcurrentDictionary<string, string> PathToSeriesIdDictionary = new(); @@ -168,7 +168,7 @@ public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries continue; } if (!tagMap.TryGetValue(parentKey, out var list)) - tagMap[parentKey] = list = new(); + tagMap[parentKey] = list = []; // Remove comment on tag name itself. if (tag.Name.Contains(" - ")) tag.Name = tag.Name.Split(" - ").First().Trim(); @@ -204,7 +204,7 @@ public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries // 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(); + tagMap[parentKey] = children = []; // Add the child tag to the parent's children if needed. var childTag = children.Find(t => string.Equals(name, t.Name, StringComparison.InvariantCultureIgnoreCase)); @@ -228,7 +228,7 @@ public Task<IReadOnlyDictionary<string, ResolvedTag>> GetNamespacedTagsForSeries // 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(); + tagMap[lastParentKey] = lastChildren = []; if (!lastChildren.Any(childTag => string.Equals(childTag.Name, tag.Name, StringComparison.InvariantCultureIgnoreCase))) lastChildren.Add(new() { @@ -506,7 +506,7 @@ internal void AddFileLookupIds(string path, string fileId, string seriesId, IEnu return await CreateFileInfo(file, fileId, seriesId).ConfigureAwait(false); } - private static readonly EpisodeType[] EpisodePickOrder = { EpisodeType.Special, EpisodeType.Normal, EpisodeType.Other }; + private static readonly EpisodeType[] EpisodePickOrder = [EpisodeType.Special, EpisodeType.Normal, EpisodeType.Other]; private Task<FileInfo> CreateFileInfo(File file, string fileId, string seriesId) => DataCache.GetOrCreateAsync( diff --git a/Shokofin/Collections/CollectionManager.cs b/Shokofin/Collections/CollectionManager.cs index f1d4704a..ff2064ab 100644 --- a/Shokofin/Collections/CollectionManager.cs +++ b/Shokofin/Collections/CollectionManager.cs @@ -156,7 +156,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, if (parentDict.TryGetValue(collection.Id, out var parents)) { foreach (var parentId in parents) { if (!toRemove.ContainsKey(parentId) && collection.ParentId != parentId) - await Collection.RemoveFromCollectionAsync(parentId, new[] { id }).ConfigureAwait(false); + await Collection.RemoveFromCollectionAsync(parentId, [id]).ConfigureAwait(false); } } @@ -178,7 +178,7 @@ private async Task ReconstructMovieSeriesCollections(IProgress<double> progress, ProviderIds = new() { { ShokoCollectionSeriesId.Name, missingId } }, }).ConfigureAwait(false); - childDict.Add(collection.Id, new()); + childDict.Add(collection.Id, []); toCheck.Add(missingId, collection); } @@ -400,7 +400,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc if (parentDict.TryGetValue(collection.Id, out var parents)) { foreach (var parentId in parents) { if (!toRemove.ContainsKey(parentId) && collection.ParentId != parentId) - await Collection.RemoveFromCollectionAsync(parentId, new[] { id }).ConfigureAwait(false); + await Collection.RemoveFromCollectionAsync(parentId, [id]).ConfigureAwait(false); } } @@ -431,7 +431,7 @@ private async Task ReconstructSharedCollections(IProgress<double> progress, Canc ProviderIds = new() { { ShokoCollectionGroupId.Name, missingId } }, }).ConfigureAwait(false); - childDict.Add(collection.Id, new()); + childDict.Add(collection.Id, []); toCheck.Add(missingId, collection); toAdd.RemoveAt(index); } @@ -633,7 +633,7 @@ private List<Movie> GetMovies() { return LibraryManager.GetItemList(new() { - IncludeItemTypes = new[] { BaseItemKind.Movie }, + IncludeItemTypes = [BaseItemKind.Movie], HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, @@ -647,7 +647,7 @@ private List<Series> GetShows() { return LibraryManager.GetItemList(new() { - IncludeItemTypes = new[] { BaseItemKind.Series }, + IncludeItemTypes = [BaseItemKind.Series], HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, @@ -661,7 +661,7 @@ private Dictionary<string, IReadOnlyList<BoxSet>> GetSeriesCollections() { return LibraryManager.GetItemList(new() { - IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + IncludeItemTypes = [BaseItemKind.BoxSet], HasAnyProviderId = new Dictionary<string, string> { { ShokoCollectionSeriesId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, @@ -677,7 +677,7 @@ private Dictionary<string, IReadOnlyList<BoxSet>> GetGroupCollections() { return LibraryManager.GetItemList(new() { - IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + IncludeItemTypes = [BaseItemKind.BoxSet], HasAnyProviderId = new Dictionary<string, string> { { ShokoCollectionGroupId.Name, string.Empty } }, IsVirtualItem = false, diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index a99b3f80..671b5b6c 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -3,7 +3,7 @@ using System.Text.Json.Serialization; using System.Xml.Serialization; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Library; + using LibraryFilteringMode = Shokofin.Utils.Ordering.LibraryFilteringMode; namespace Shokofin.Configuration; diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 5b1909a2..9f232f88 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -49,7 +49,7 @@ public class MediaFolderConfigurationService private readonly NamingOptions NamingOptions; - private readonly Dictionary<Guid, string> MediaFolderChangeKeys = new(); + private readonly Dictionary<Guid, string> MediaFolderChangeKeys = []; private readonly object LockObj = new(); @@ -164,11 +164,11 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder); lock (LockObj) { if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) - return (string.Empty, string.Empty, null, new List<MediaFolderConfiguration>()); + return (string.Empty, string.Empty, null, []); var virtualFolder = LibraryManager.GetVirtualFolders() .FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == mediaFolderConfig.LibraryId); if (virtualFolder is null || virtualFolder.Locations.Length is 0) - return (string.Empty, string.Empty, null, new List<MediaFolderConfiguration>()); + return (string.Empty, string.Empty, null, []); return ( libraryFolder.GetVirtualRoot(), virtualFolder.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty, diff --git a/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs b/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs index c9d44ef9..a271437c 100644 --- a/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs +++ b/Shokofin/Configuration/Models/MediaFolderConfigurationChangedEventArgs.cs @@ -1,6 +1,5 @@ using System; using MediaBrowser.Controller.Entities; -using Shokofin.Configuration; namespace Shokofin.Configuration.Models; diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index d3eba747..0c8cbef2 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,6 +1,5 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; -using System.Linq; using System.Text.Json.Serialization; using System.Xml.Serialization; using MediaBrowser.Model.Plugins; @@ -479,17 +478,17 @@ public PluginConfiguration() GenreMaximumDepth = 1; HideUnverifiedTags = true; ContentRatingOverride = false; - ContentRatingList = new[] { + ContentRatingList = [ ProviderName.AniDB, ProviderName.TMDB, - }; - ContentRatingOrder = ContentRatingList.ToArray(); + ]; + ContentRatingOrder = [.. ContentRatingList]; ProductionLocationOverride = false; - ProductionLocationList = new[] { + ProductionLocationList = [ ProviderName.AniDB, ProviderName.TMDB, - }; - ProductionLocationOrder = ProductionLocationList.ToArray(); + ]; + ProductionLocationOrder = [.. ProductionLocationList]; SynopsisCleanLinks = true; SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; @@ -497,10 +496,10 @@ public PluginConfiguration() AddAniDBId = true; AddTMDBId = true; TitleMainOverride = false; - TitleMainList = new[] { + TitleMainList = [ TitleProvider.Shoko_Default, - }; - TitleMainOrder = new[] { + ]; + TitleMainOrder = [ TitleProvider.Shoko_Default, TitleProvider.AniDB_Default, TitleProvider.AniDB_LibraryLanguage, @@ -508,24 +507,24 @@ public PluginConfiguration() TitleProvider.TMDB_Default, TitleProvider.TMDB_LibraryLanguage, TitleProvider.TMDB_CountryOfOrigin, - }; + ]; TitleAlternateOverride = false; - TitleAlternateList = new[] { + TitleAlternateList = [ TitleProvider.AniDB_CountryOfOrigin - }; - TitleAlternateOrder = TitleMainOrder.ToArray(); + ]; + TitleAlternateOrder = [.. TitleMainOrder]; TitleAllowAny = false; DescriptionSourceOverride = false; - DescriptionSourceList = new[] { + DescriptionSourceList = [ DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB, - }; - DescriptionSourceOrder = new[] { + ]; + DescriptionSourceOrder = [ DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB, - }; + ]; VFS_Enabled = CanCreateSymbolicLinks; VFS_Threads = 4; VFS_AddReleaseGroup = false; @@ -544,19 +543,19 @@ public PluginConfiguration() MarkSpecialsWhenGrouped = true; CollectionGrouping = CollectionCreationType.None; CollectionMinSizeOfTwo = true; - UserList = new(); - MediaFolders = new(); - IgnoredFolders = new[] { ".streams", "@recently-snapshot" }; + UserList = []; + MediaFolders = []; + IgnoredFolders = [".streams", "@recently-snapshot"]; LibraryFilteringMode = LibraryFilteringMode.Auto; LibraryScanReactionTimeInSeconds = 1; SignalR_AutoConnectEnabled = false; - SignalR_AutoReconnectInSeconds = new[] { 0, 2, 10, 30, 60, 120, 300 }; - SignalR_EventSources = new[] { ProviderName.Shoko, ProviderName.AniDB, ProviderName.TMDB }; + SignalR_AutoReconnectInSeconds = [0, 2, 10, 30, 60, 120, 300]; + SignalR_EventSources = [ProviderName.Shoko, ProviderName.AniDB, ProviderName.TMDB]; SignalR_RefreshEnabled = false; SignalR_FileEvents = false; UsageTracker_StalledTimeInSeconds = 10; EXPERIMENTAL_MergeSeasons = false; - EXPERIMENTAL_MergeSeasonsTypes = new[] { SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA }; + EXPERIMENTAL_MergeSeasonsTypes = [SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA]; EXPERIMENTAL_MergeSeasonsMergeWindowInDays = 185; } } diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index fa20313b..deeaad88 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -55,11 +55,11 @@ public class EventDispatchService private readonly Timer ChangesDetectionTimer; - private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List, Guid trackerId)> ChangesPerSeries = new(); + private readonly Dictionary<string, (DateTime LastUpdated, List<IMetadataUpdatedEventArgs> List, Guid trackerId)> ChangesPerSeries = []; - private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List, Guid trackerId)> ChangesPerFile = new(); + private readonly Dictionary<int, (DateTime LastUpdated, List<(UpdateReason Reason, int ImportFolderId, string Path, IFileEventArgs Event)> List, Guid trackerId)> ChangesPerFile = []; - private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = new(); + private readonly Dictionary<string, (int refCount, DateTime delayEnd)> MediaFolderChangeMonitor = []; // It's so magical that it matches the magical value in the library monitor in JF core. 🪄 private const int MagicalDelayValue = 45000; @@ -192,7 +192,7 @@ public void AddFileEvent(int fileId, UpdateReason reason, int importFolderId, st if (ChangesPerFile.TryGetValue(fileId, out var tuple)) tuple.LastUpdated = DateTime.Now; else - ChangesPerFile.Add(fileId, tuple = (DateTime.Now, new(), Plugin.Instance.Tracker.Add($"File event. (Reason=\"{reason}\",ImportFolder={eventArgs.ImportFolderId},RelativePath=\"{eventArgs.RelativePath}\")"))); + ChangesPerFile.Add(fileId, tuple = (DateTime.Now, [], Plugin.Instance.Tracker.Add($"File event. (Reason=\"{reason}\",ImportFolder={eventArgs.ImportFolderId},RelativePath=\"{eventArgs.RelativePath}\")"))); tuple.List.Add((reason, importFolderId, filePath, eventArgs)); } } @@ -518,7 +518,7 @@ public void AddSeriesEvent(string metadataId, IMetadataUpdatedEventArgs eventArg if (ChangesPerSeries.TryGetValue(metadataId, out var tuple)) tuple.LastUpdated = DateTime.Now; else - ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, new(), Plugin.Instance.Tracker.Add($"Metadata event. (Reason=\"{eventArgs.Reason}\",Kind=\"{eventArgs.Kind}\",ProviderUId=\"{eventArgs.ProviderUId}\")"))); + ChangesPerSeries.Add(metadataId, tuple = (DateTime.Now, [], Plugin.Instance.Tracker.Add($"Metadata event. (Reason=\"{eventArgs.Reason}\",Kind=\"{eventArgs.Kind}\",ProviderUId=\"{eventArgs.ProviderUId}\")"))); tuple.List.Add(eventArgs); } } @@ -573,7 +573,7 @@ private async Task<int> ProcessSeriesEvents(ShowInfo showInfo, List<IMetadataUpd var shows = LibraryManager .GetItemList( new() { - IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Series }, + IncludeItemTypes = [BaseItemKind.Series], HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, showInfo.Id } }, DtoOptions = new(true), }, @@ -612,7 +612,7 @@ await show.RefreshMetadata(new(DirectoryService) { var seasons = LibraryManager .GetItemList( new() { - IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Season }, + IncludeItemTypes = [BaseItemKind.Season], HasAnyProviderId = new Dictionary<string, string> { { ShokoSeriesId.Name, seasonInfo.Id } }, DtoOptions = new(true), }, @@ -643,7 +643,7 @@ await season.RefreshMetadata(new(DirectoryService) { var episodes = LibraryManager .GetItemList( new() { - IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Episode }, + IncludeItemTypes = [BaseItemKind.Episode], HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, DtoOptions = new(true), }, @@ -686,7 +686,7 @@ private async Task<int> ProcessMovieEvents(SeasonInfo seasonInfo, List<IMetadata var movies = LibraryManager .GetItemList( new() { - IncludeItemTypes = new BaseItemKind[] { BaseItemKind.Movie }, + IncludeItemTypes = [BaseItemKind.Movie], HasAnyProviderId = new Dictionary<string, string> { { ShokoEpisodeId.Name, episodeInfo.Id } }, DtoOptions = new(true), }, diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index e7f68ae0..dd0c37f0 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -95,7 +95,7 @@ public IdLookup(ShokoAPIManager apiManager, ILibraryManager libraryManager) #region Base Item - private readonly HashSet<string> AllowedTypes = new() { nameof(Series), nameof(Season), nameof(Episode), nameof(Movie) }; + private readonly HashSet<string> AllowedTypes = [nameof(Series), nameof(Season), nameof(Episode), nameof(Movie)]; public bool IsEnabledForItem(BaseItem item) => IsEnabledForItem(item, out var _); @@ -225,7 +225,7 @@ public bool TryGetEpisodeIdsFor(string path, [NotNullWhen(true)] out List<string if (ApiManager.TryGetEpisodeIdsForPath(path, out episodeIds)) return true; - episodeIds = new(); + episodeIds = []; return false; } diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index bbd4dbd8..dbd7b896 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -119,7 +119,7 @@ public async Task SplitAll(IProgress<double> progress, CancellationToken cancell private List<Movie> GetMoviesFromLibrary() { return LibraryManager.GetItemList(new() { - IncludeItemTypes = new[] { BaseItemKind.Movie }, + IncludeItemTypes = [BaseItemKind.Movie], IsVirtualItem = false, Recursive = true, HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, @@ -217,7 +217,7 @@ public async Task SplitAllMovies(IProgress<double> progress, CancellationToken c private List<Episode> GetEpisodesFromLibrary() { return LibraryManager.GetItemList(new() { - IncludeItemTypes = new[] { BaseItemKind.Episode }, + IncludeItemTypes = [BaseItemKind.Episode], HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, IsVirtualItem = false, Recursive = true, @@ -350,7 +350,7 @@ private static async Task MergeVideos(List<Video> videos) // Reset the linked alternate versions for the linked videos. if (video.LinkedAlternateVersions.Length > 0) - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.LinkedAlternateVersions = []; // Save the changes back to the repository. await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) @@ -389,14 +389,14 @@ private async Task RemoveAlternateSources(Video video) foreach (var linkedVideo in video.GetLinkedAlternateVersions()) { linkedVideo.SetPrimaryVersionId(null); - linkedVideo.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + linkedVideo.LinkedAlternateVersions = []; await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } // Remove the link for the primary video. video.SetPrimaryVersionId(null); - video.LinkedAlternateVersions = Array.Empty<LinkedChild>(); + video.LinkedAlternateVersions = []; await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 7af4026a..f6c7a25b 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -186,8 +186,8 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) public IEnumerable<PluginPageInfo> GetPages() { - return new[] - { + return + [ new PluginPageInfo { Name = Name, @@ -198,6 +198,6 @@ public IEnumerable<PluginPageInfo> GetPages() Name = "ShokoController.js", EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configController.js", }, - }; + ]; } } diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 1134c197..41e1fd7c 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -108,7 +108,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoGroupMetadata(BoxSetInfo info } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) diff --git a/Shokofin/Providers/CustomBoxSetProvider.cs b/Shokofin/Providers/CustomBoxSetProvider.cs index c8cd2fac..33783a15 100644 --- a/Shokofin/Providers/CustomBoxSetProvider.cs +++ b/Shokofin/Providers/CustomBoxSetProvider.cs @@ -139,7 +139,7 @@ private async Task<BoxSet> GetCollectionByGroupId(Folder collectionRoot, string? var list = LibraryManager.GetItemList(new() { - IncludeItemTypes = new[] { BaseItemKind.BoxSet }, + IncludeItemTypes = [BaseItemKind.BoxSet], HasAnyProviderId = new() { { ShokoCollectionGroupId.Name, collectionId } }, IsVirtualItem = false, diff --git a/Shokofin/Providers/CustomEpisodeProvider.cs b/Shokofin/Providers/CustomEpisodeProvider.cs index e096bd9d..db244c41 100644 --- a/Shokofin/Providers/CustomEpisodeProvider.cs +++ b/Shokofin/Providers/CustomEpisodeProvider.cs @@ -57,9 +57,9 @@ public static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logg // Remove any extra virtual episodes that matches the newly refreshed episode. var searchList = libraryManager.GetItemList( new() { - ExcludeItemIds = new[] { episode.Id }, + ExcludeItemIds = [episode.Id], HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, - IncludeItemTypes = new[] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + IncludeItemTypes = [Jellyfin.Data.Enums.BaseItemKind.Episode], GroupByPresentationUniqueKey = false, GroupBySeriesPresentationUniqueKey = true, SeriesPresentationUniqueKey = seriesPresentationUniqueKey, @@ -86,7 +86,7 @@ private static bool EpisodeExists(ILibraryManager libraryManager, ILogger logger { var searchList = libraryManager.GetItemList( new() { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Episode }, + IncludeItemTypes = [Jellyfin.Data.Enums.BaseItemKind.Episode], HasAnyProviderId = new() { { ShokoEpisodeId.Name, episodeId } }, GroupByPresentationUniqueKey = false, GroupBySeriesPresentationUniqueKey = true, diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs index 22e85720..70f1b021 100644 --- a/Shokofin/Providers/CustomSeasonProvider.cs +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -133,7 +133,7 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio var episodeList = Math.Abs(seasonNumber - baseSeasonNumber) == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; var knownEpisodeIds = ShouldAddMetadata ? episodeList.Select(episodeInfo => episodeInfo.Id).ToHashSet() - : new HashSet<string>(); + : []; var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); foreach (var episode in season.Children.OfType<Episode>()) { @@ -186,8 +186,8 @@ private static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger log .GetItemList( new() { ParentId = season.ParentId, - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, - ExcludeItemIds = new [] { season.Id }, + IncludeItemTypes = [Jellyfin.Data.Enums.BaseItemKind.Season], + ExcludeItemIds = [season.Id], IndexNumber = seasonNumber, DtoOptions = new(true), }, @@ -212,7 +212,7 @@ private static bool SeasonExists(ILibraryManager libraryManager, ILogger logger, { var searchList = libraryManager.GetItemList( new() { - IncludeItemTypes = new [] { Jellyfin.Data.Enums.BaseItemKind.Season }, + IncludeItemTypes = [Jellyfin.Data.Enums.BaseItemKind.Season], IndexNumber = seasonNumber, GroupByPresentationUniqueKey = false, GroupBySeriesPresentationUniqueKey = true, diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index a20d40a7..c6e78a85 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -173,7 +173,7 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio // Get known episodes, existing episodes, and episodes to remove. var episodeList = Math.Abs(seasonNumber - baseSeasonNumber) == 0 ? seasonInfo.EpisodeList : seasonInfo.AlternateEpisodesList; - var knownEpisodeIds = ShouldAddMetadata ? episodeList.Select(episodeInfo => episodeInfo.Id).ToHashSet() : new HashSet<string>(); + var knownEpisodeIds = ShouldAddMetadata ? episodeList.Select(episodeInfo => episodeInfo.Id).ToHashSet() : []; var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); foreach (var episode in season.Children.OfType<Episode>()) { diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 52188074..10a7caa0 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -252,7 +252,7 @@ private static void AddProviderIds(IHasProviderIds item, string episodeId, strin } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 42e95a60..0cc8aa54 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -171,7 +171,7 @@ private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType } public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - => new[] { ImageType.Primary, ImageType.Backdrop, ImageType.Banner, ImageType.Logo }; + => [ImageType.Primary, ImageType.Backdrop, ImageType.Banner, ImageType.Logo]; public bool Supports(BaseItem item) => item is Series or Season or Episode or Movie or BoxSet; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 21164f93..6c598e5e 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -92,7 +92,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 0eb18d87..4f145a38 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -164,7 +164,7 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index cba803ac..de045382 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -118,7 +118,7 @@ public static void AddProviderIds(IHasProviderIds item, string seriesId, string? } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo info, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); diff --git a/Shokofin/Providers/TrailerProvider.cs b/Shokofin/Providers/TrailerProvider.cs index 706a2bac..22d6eefd 100644 --- a/Shokofin/Providers/TrailerProvider.cs +++ b/Shokofin/Providers/TrailerProvider.cs @@ -77,7 +77,7 @@ public async Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, Cancell } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TrailerInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); diff --git a/Shokofin/Providers/VideoProvider.cs b/Shokofin/Providers/VideoProvider.cs index 31644aa8..cafd8897 100644 --- a/Shokofin/Providers/VideoProvider.cs +++ b/Shokofin/Providers/VideoProvider.cs @@ -77,7 +77,7 @@ public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, Cancel } public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ItemLookupInfo searchInfo, CancellationToken cancellationToken) - => Task.FromResult<IEnumerable<RemoteSearchResult>>(new List<RemoteSearchResult>()); + => Task.FromResult<IEnumerable<RemoteSearchResult>>([]); public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) => HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); diff --git a/Shokofin/Resolvers/Models/LinkGenerationResult.cs b/Shokofin/Resolvers/Models/LinkGenerationResult.cs index 9c9cfe19..51d89070 100644 --- a/Shokofin/Resolvers/Models/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/Models/LinkGenerationResult.cs @@ -9,7 +9,7 @@ public class LinkGenerationResult { private DateTime CreatedAt { get; init; } = DateTime.Now; - public ConcurrentBag<string> Paths { get; init; } = new(); + public ConcurrentBag<string> Paths { get; init; } = []; public int Total => TotalVideos + TotalSubtitles; diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index 5f2e31ff..4d1ccdfb 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -309,7 +309,7 @@ await Cache.GetOrCreateAsync( } Logger.LogTrace("Failed to get file info from Shoko during a file deleted event. (File={FileId})", fileId); - eventArgs = new FileEventArgsStub(int.Parse(fileId), null, mediaConfig.ImportFolderId, relativePath, Array.Empty<IFileEventArgs.FileCrossReference>()); + eventArgs = new FileEventArgsStub(int.Parse(fileId), null, mediaConfig.ImportFolderId, relativePath, []); } finally { Plugin.Instance.Tracker.Remove(trackerId); diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 905a86d4..06d894b9 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -135,7 +135,7 @@ NamingOptions namingOptions .AsParallel() .SelectMany(dirInfo => { if (!dirInfo.Name.TryGetAttributeValue(ShokoSeriesId.Name, out var seriesId) || !int.TryParse(seriesId, out _)) - return Array.Empty<BaseItem>(); + return []; var season = ApiManager.GetSeasonInfoForSeries(seriesId) .ConfigureAwait(false) @@ -143,7 +143,7 @@ NamingOptions namingOptions .GetResult(); if (season is null) { pathsToRemoveBag.Add((dirInfo.FullName, true)); - return Array.Empty<BaseItem>(); + return []; } if (createMovies && (season.Type is SeriesType.Movie || collectionType is CollectionType.movies && !Plugin.Instance.Configuration.FilterMovieLibraries)) { @@ -181,11 +181,11 @@ NamingOptions namingOptions .ToArray(); } - return new BaseItem[1] { + return [ new TvSeries() { Path = dirInfo.FullName, }, - }; + ]; }) .OfType<BaseItem>() .ToList(); diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 0c7993fe..c9e54675 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -892,7 +892,7 @@ private List<string> FindSubtitlesForPath(string sourcePath) return externalPaths; var files = FileSystem.GetFilePaths(folderPath) - .Except(new[] { sourcePath }) + .Except([sourcePath]) .ToList(); var sourcePrefix = Path.GetFileNameWithoutExtension(sourcePath); foreach (var file in files) { diff --git a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs index 5af67540..2325e4d6 100644 --- a/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/EpisodeInfoUpdatedEventArgs.cs @@ -35,19 +35,19 @@ public class EpisodeInfoUpdatedEventArgs : IMetadataUpdatedEventArgs /// Shoko episode ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoEpisodeIDs")] - public List<int> EpisodeIds { get; set; } = new(); + public List<int> EpisodeIds { get; set; } = []; /// <summary> /// Shoko series ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] - public List<int> SeriesIds { get; set; } = new(); + public List<int> SeriesIds { get; set; } = []; /// <summary> /// Shoko group ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoGroupIDs")] - public List<int> GroupIds { get; set; } = new(); + public List<int> GroupIds { get; set; } = []; #region IMetadataUpdatedEventArgs Impl. diff --git a/Shokofin/SignalR/Models/FileEventArgs.cs b/Shokofin/SignalR/Models/FileEventArgs.cs index f2fb1a26..74a25d74 100644 --- a/Shokofin/SignalR/Models/FileEventArgs.cs +++ b/Shokofin/SignalR/Models/FileEventArgs.cs @@ -54,7 +54,7 @@ public string RelativePath /// <inheritdoc/> [JsonIgnore] - public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = new(); + public List<IFileEventArgs.FileCrossReference> CrossReferences { get; set; } = []; #pragma warning disable IDE0051 /// <summary> diff --git a/Shokofin/SignalR/Models/FileMovedEventArgs.cs b/Shokofin/SignalR/Models/FileMovedEventArgs.cs index e97234c4..5a5f1e58 100644 --- a/Shokofin/SignalR/Models/FileMovedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileMovedEventArgs.cs @@ -125,6 +125,6 @@ public string PreviousRelativePath /// <inheritdoc/> [JsonIgnore] - public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); + public List<IFileEventArgs.FileCrossReference> CrossReferences => []; } } diff --git a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs index e027d062..b1f671ca 100644 --- a/Shokofin/SignalR/Models/FileRenamedEventArgs.cs +++ b/Shokofin/SignalR/Models/FileRenamedEventArgs.cs @@ -97,6 +97,6 @@ public string RelativePath /// <inheritdoc/> [JsonIgnore] - public List<IFileEventArgs.FileCrossReference> CrossReferences => new(); + public List<IFileEventArgs.FileCrossReference> CrossReferences => []; } } \ No newline at end of file diff --git a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs index 5bd28159..78f5d0fa 100644 --- a/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs +++ b/Shokofin/SignalR/Models/SeriesInfoUpdatedEventArgs.cs @@ -29,13 +29,13 @@ public class SeriesInfoUpdatedEventArgs : IMetadataUpdatedEventArgs /// Shoko series ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoSeriesIDs")] - public List<int> SeriesIds { get; set; } = new(); + public List<int> SeriesIds { get; set; } = []; /// <summary> /// Shoko group ids affected by this update. /// </summary> [JsonInclude, JsonPropertyName("ShokoGroupIDs")] - public List<int> GroupIds { get; set; } = new(); + public List<int> GroupIds { get; set; } = []; #region IMetadataUpdatedEventArgs Impl. @@ -43,7 +43,7 @@ public class SeriesInfoUpdatedEventArgs : IMetadataUpdatedEventArgs int? IMetadataUpdatedEventArgs.ProviderParentId => null; - IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => new List<int>(); + IReadOnlyList<int> IMetadataUpdatedEventArgs.EpisodeIds => []; IReadOnlyList<int> IMetadataUpdatedEventArgs.SeriesIds => SeriesIds; diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index f4deeb87..d14e8c52 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -375,13 +375,13 @@ public async Task ScanAndSync(SyncDirection direction, IProgress<double> progres } var videos = LibraryManager.GetItemList(new InternalItemsQuery { - MediaTypes = new[] { MediaType.Video }, + MediaTypes = [MediaType.Video], IsFolder = false, Recursive = true, DtoOptions = new DtoOptions(false) { EnableImages = false }, - SourceTypes = new SourceType[] { SourceType.Library }, + SourceTypes = [SourceType.Library], IsVirtualItem = false, }) .OfType<Video>() diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs index 384a8f85..dbc74572 100644 --- a/Shokofin/Utils/ContentRating.cs +++ b/Shokofin/Utils/ContentRating.cs @@ -156,7 +156,7 @@ public enum TvContentIndicator { private static ProviderName[] GetOrderedProviders() => Plugin.Instance.Configuration.ContentRatingOverride ? Plugin.Instance.Configuration.ContentRatingOrder.Where((t) => Plugin.Instance.Configuration.ContentRatingList.Contains(t)).ToArray() - : new ProviderName[] { ProviderName.AniDB, ProviderName.TMDB }; + : [ProviderName.AniDB, ProviderName.TMDB]; #pragma warning disable IDE0060 public static string? GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) @@ -195,7 +195,7 @@ private static ProviderName[] GetOrderedProviders() .Select(seasonInfo => GetSeasonContentRating(seasonInfo)) .Where(contentRating => !string.IsNullOrEmpty(contentRating)) .Distinct() - .Select(text => TryConvertRatingFromText(text, out var cR, out var cI) ? (contentRating: cR, contentIndicators: cI ?? new()) : (contentRating: TvRating.None, contentIndicators: new())) + .Select(text => TryConvertRatingFromText(text, out var cR, out var cI) ? (contentRating: cR, contentIndicators: cI ?? []) : (contentRating: TvRating.None, contentIndicators: [])) .Where(tuple => tuple.contentRating is not TvRating.None) .GroupBy(tuple => tuple.contentRating) .OrderByDescending(groupBy => groupBy.Key) @@ -345,7 +345,7 @@ private static bool TryConvertRatingFromText(string? value, out TvRating content } // Parse indicators. - contentIndicators = new(); + contentIndicators = []; if (value.Length <= offset) return true; foreach (var raw in value[offset..]) { @@ -361,7 +361,7 @@ private static bool TryConvertRatingFromText(string? value, out TvRating content } internal static T[] GetCustomAttributes<T>(this System.Reflection.FieldInfo? fieldInfo, bool inherit = false) - => fieldInfo?.GetCustomAttributes(typeof(T), inherit) is T[] attributes ? attributes : Array.Empty<T>(); + => fieldInfo?.GetCustomAttributes(typeof(T), inherit) is T[] attributes ? attributes : []; private static string? ConvertRatingToText(TvRating value, IEnumerable<TvContentIndicator>? contentIndicators) { @@ -371,8 +371,8 @@ internal static T[] GetCustomAttributes<T>(this System.Reflection.FieldInfo? fie return null; var contentRating = attributes.First().Description; - var allowedIndicators = (field.GetCustomAttributes<TvContentIndicatorsAttribute>().FirstOrDefault()?.Values ?? Array.Empty<TvContentIndicator>()) - .Intersect(contentIndicators ?? Array.Empty<TvContentIndicator>()) + var allowedIndicators = (field.GetCustomAttributes<TvContentIndicatorsAttribute>().FirstOrDefault()?.Values ?? []) + .Intersect(contentIndicators ?? []) .ToList(); if (allowedIndicators.Count is > 0) contentRating += $"-{allowedIndicators.Select(cI => cI.ToString()).Join("")}"; diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs index 778fa185..d69f5991 100644 --- a/Shokofin/Utils/TagFilter.cs +++ b/Shokofin/Utils/TagFilter.cs @@ -321,7 +321,7 @@ public enum TagWeight { private static ProviderName[] GetOrderedProductionLocationProviders() => Plugin.Instance.Configuration.ProductionLocationOverride ? Plugin.Instance.Configuration.ProductionLocationOrder.Where((t) => Plugin.Instance.Configuration.ProductionLocationList.Contains(t)).ToArray() - : new[] { ProviderName.AniDB, ProviderName.TMDB }; + : [ProviderName.AniDB, ProviderName.TMDB]; #pragma warning disable IDE0060 public static IReadOnlyList<string> GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) @@ -332,12 +332,12 @@ public static IReadOnlyList<string> GetMovieContentRating(SeasonInfo seasonInfo, var title = provider switch { ProviderName.AniDB => seasonInfo.ProductionLocations, // TODO: Add TMDB series content rating here. - _ => Array.Empty<string>(), + _ => [], }; if (title.Count > 0) return title; } - return Array.Empty<string>(); + return []; } public static IReadOnlyList<string> GetSeasonContentRating(SeasonInfo seasonInfo) @@ -346,12 +346,12 @@ public static IReadOnlyList<string> GetSeasonContentRating(SeasonInfo seasonInfo var title = provider switch { ProviderName.AniDB => seasonInfo.ProductionLocations, // TODO: Add TMDB series content rating here. - _ => Array.Empty<string>(), + _ => [], }; if (title.Count > 0) return title; } - return Array.Empty<string>(); + return []; } public static IReadOnlyList<string> GetShowContentRating(ShowInfo showInfo) @@ -360,12 +360,12 @@ public static IReadOnlyList<string> GetShowContentRating(ShowInfo showInfo) var title = provider switch { ProviderName.AniDB => showInfo.ProductionLocations, // TODO: Add TMDB series content rating here. - _ => Array.Empty<string>(), + _ => [], }; if (title.Count > 0) return title; } - return Array.Empty<string>(); + return []; } public static string[] FilterTags(IReadOnlyDictionary<string, ResolvedTag> tags) @@ -401,9 +401,9 @@ public static string[] FilterGenres(IReadOnlyDictionary<string, ResolvedTag> tag return FilterInternal(tags, config.GenreSources, config.GenreIncludeFilters, config.GenreMinimumWeight, config.GenreMaximumDepth); } - private static readonly HashSet<TagSource> AllFlagsToUse = Enum.GetValues<TagSource>().Except(new[] { TagSource.CustomTags }).ToHashSet(); + private static readonly HashSet<TagSource> AllFlagsToUse = Enum.GetValues<TagSource>().Except([TagSource.CustomTags]).ToHashSet(); - private static readonly HashSet<TagSource> AllFlagsToUseForCustomTags = AllFlagsToUse.Except(new[] { TagSource.SourceMaterial, TagSource.TargetAudience }).ToHashSet(); + private static readonly HashSet<TagSource> AllFlagsToUseForCustomTags = AllFlagsToUse.Except([TagSource.SourceMaterial, TagSource.TargetAudience]).ToHashSet(); private static string[] FilterInternal(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight = TagWeight.Weightless, int maxDepth = 0) { @@ -431,7 +431,7 @@ private static string[] FilterInternal(IReadOnlyDictionary<string, ResolvedTag> private static HashSet<string> GetTagsFromSource(IReadOnlyDictionary<string, ResolvedTag> tags, TagSource source, TagIncludeFilter includeFilter, TagWeight minWeight, int maxDepth) { if (source is TagSource.SourceMaterial) - return new() { GetSourceMaterial(tags) }; + return [GetSourceMaterial(tags)]; var tagSet = new HashSet<string>(); var exceptTags = new List<ResolvedTag>(); @@ -519,29 +519,29 @@ private static string GetSourceMaterial(IReadOnlyDictionary<string, ResolvedTag> public static string[] GetProductionCountriesFromTags(IReadOnlyDictionary<string, ResolvedTag> tags) { if (!tags.TryGetValue("/origin", out var origin)) - return Array.Empty<string>(); + return []; var productionCountries = new List<string>(); foreach (var childTag in origin.Children.Keys) { productionCountries.AddRange(childTag.ToLowerInvariant() switch { - "american-japanese co-production" => new string[] {"Japan", "United States of America" }, - "chinese production" => new string[] {"China" }, - "french-chinese co-production" => new string[] {"France", "China" }, - "french-japanese co-production" => new string[] {"Japan", "France" }, - "indo-japanese co-production" => new string[] {"Japan", "India" }, - "japanese production" => new string[] {"Japan" }, - "korean-japanese co-production" => new string[] {"Japan", "Republic of Korea" }, - "north korean production" => new string[] {"Democratic People's Republic of Korea" }, - "polish-japanese co-production" => new string[] {"Japan", "Poland" }, - "russian-japanese co-production" => new string[] {"Japan", "Russia" }, - "saudi arabian-japanese co-production" => new string[] {"Japan", "Saudi Arabia" }, - "italian-japanese co-production" => new string[] {"Japan", "Italy" }, - "singaporean production" => new string[] {"Singapore" }, - "sino-japanese co-production" => new string[] {"Japan", "China" }, - "south korea production" => new string[] {"Republic of Korea" }, - "taiwanese production" => new string[] {"Taiwan" }, - "thai production" => new string[] {"Thailand" }, - _ => Array.Empty<string>(), + "american-japanese co-production" => new string[] { "Japan", "United States of America" }, + "chinese production" => ["China"], + "french-chinese co-production" => ["France", "China"], + "french-japanese co-production" => ["Japan", "France"], + "indo-japanese co-production" => ["Japan", "India"], + "japanese production" => ["Japan"], + "korean-japanese co-production" => ["Japan", "Republic of Korea"], + "north korean production" => ["Democratic People's Republic of Korea"], + "polish-japanese co-production" => ["Japan", "Poland"], + "russian-japanese co-production" => ["Japan", "Russia"], + "saudi arabian-japanese co-production" => ["Japan", "Saudi Arabia"], + "italian-japanese co-production" => ["Japan", "Italy"], + "singaporean production" => ["Singapore"], + "sino-japanese co-production" => ["Japan", "China"], + "south korea production" => ["Republic of Korea"], + "taiwanese production" => ["Taiwan"], + "thai production" => ["Thailand"], + _ => [], }); } return productionCountries diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index eccaf328..e1ca161b 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -9,7 +9,7 @@ namespace Shokofin.Utils; public static class Text { - private static readonly HashSet<char> PunctuationMarks = new() { + private static readonly HashSet<char> PunctuationMarks = [ // Common punctuation marks '.', // period ',', // comma @@ -57,7 +57,7 @@ public static class Text '⦊', // right angle bracket '⦌', // right angle bracket '⦎', // right angle bracket - }; + ]; private static readonly HashSet<string> IgnoredSubTitles = new(StringComparer.InvariantCultureIgnoreCase) { "Complete Movie", @@ -181,7 +181,7 @@ public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) private static DescriptionProvider[] GetOrderedDescriptionProviders() => Plugin.Instance.Configuration.DescriptionSourceOverride ? Plugin.Instance.Configuration.DescriptionSourceOrder.Where((t) => Plugin.Instance.Configuration.DescriptionSourceList.Contains(t)).ToArray() - : new[] { DescriptionProvider.Shoko, DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB }; + : [DescriptionProvider.Shoko, DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB]; private static string GetDescriptionByDict(Dictionary<DescriptionProvider, string?> descriptions) { @@ -324,12 +324,12 @@ private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType TitleProviderType.Main => Plugin.Instance.Configuration.TitleMainOverride ? Plugin.Instance.Configuration.TitleMainOrder.Where((t) => Plugin.Instance.Configuration.TitleMainList.Contains(t)).ToArray() - : new[] { TitleProvider.Shoko_Default }, + : [TitleProvider.Shoko_Default], TitleProviderType.Alternate => Plugin.Instance.Configuration.TitleAlternateOverride ? Plugin.Instance.Configuration.TitleAlternateOrder.Where((t) => Plugin.Instance.Configuration.TitleAlternateList.Contains(t)).ToArray() - : new[] { TitleProvider.AniDB_CountryOfOrigin, TitleProvider.TMDB_CountryOfOrigin }, - _ => Array.Empty<TitleProvider>(), + : [TitleProvider.AniDB_CountryOfOrigin, TitleProvider.TMDB_CountryOfOrigin], + _ => [], }; private static string? GetMovieTitleByType(EpisodeInfo episodeInfo, SeasonInfo seasonInfo, TitleProviderType type, string? metadataLanguage) @@ -431,9 +431,9 @@ private static string GetMainLanguage(IEnumerable<Title> titles) /// <returns>The list of origin language codes to try and use.</returns> private static string[] GuessOriginLanguage(string langCode) => langCode switch { - "x-other" => new string[] { "ja" }, - "x-jat" => new string[] { "ja" }, - "x-zht" => new string[] { "zn-hans", "zn-hant", "zn-c-mcm", "zn" }, - _ => new string[] { langCode }, + "x-other" => ["ja"], + "x-jat" => ["ja"], + "x-zht" => ["zn-hans", "zn-hant", "zn-c-mcm", "zn"], + _ => [langCode], }; } From 50f10a32d3690f399db669f2a07a7b2dab69f735 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 03:19:29 +0200 Subject: [PATCH 051/144] chore: update settings page - Updated some settings to better align with the new docs. Still need work though, but it's a start. --- Shokofin/Configuration/PluginConfiguration.cs | 46 ++++++++++++- Shokofin/Configuration/configController.js | 9 +-- Shokofin/Configuration/configPage.html | 69 ++++++++++++------- 3 files changed, 90 insertions(+), 34 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 0c8cbef2..baea5053 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -1,5 +1,6 @@ using System.Collections.Generic; using System.ComponentModel.DataAnnotations; +using System.Linq; using System.Text.Json.Serialization; using System.Xml.Serialization; using MediaBrowser.Model.Plugins; @@ -69,16 +70,54 @@ public virtual string PrettyUrl #region Plugin Interoperability /// <summary> - /// Add AniDB ids to entries that support it. This is best to use when you - /// don't use shoko groups. + /// Add IDs from the enabled provider to entities that support it. /// </summary> + /// <remarks> + /// This is not stored in the xml config file to not break the existing + /// settings model until the next major version of the plugin. + /// </remarks> + /// TODO: Break this during the next major version of the plugin. + [JsonInclude] + [XmlIgnore] + public DescriptionProvider[] ThirdPartyIdProviderList + { + get + { + var list = new List<DescriptionProvider>(); + if (AddAniDBId) + list.Add(DescriptionProvider.AniDB); + if (AddTvDBId) + list.Add(DescriptionProvider.TvDB); + if (AddTMDBId) + list.Add(DescriptionProvider.TMDB); + return [.. list]; + } + set + { + AddAniDBId = value.Contains(DescriptionProvider.AniDB); + AddTvDBId = value.Contains(DescriptionProvider.TvDB); + AddTMDBId = value.Contains(DescriptionProvider.TMDB); + } + } + + /// <summary> + /// Add AniDB ids to entries that support it. + /// </summary> + [JsonIgnore] public bool AddAniDBId { get; set; } /// <summary> /// Add TMDb ids to entries that support it. /// </summary> + [JsonIgnore] public bool AddTMDBId { get; set; } + /// <summary> + /// Add TvDB ids to entries that support it. + /// </summary> + [JsonIgnore] + public bool AddTvDBId { get; set; } + #endregion #region Metadata @@ -479,8 +518,8 @@ public PluginConfiguration() HideUnverifiedTags = true; ContentRatingOverride = false; ContentRatingList = [ - ProviderName.AniDB, ProviderName.TMDB, + ProviderName.AniDB, ]; ContentRatingOrder = [.. ContentRatingList]; ProductionLocationOverride = false; @@ -495,6 +534,7 @@ public PluginConfiguration() SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; AddTMDBId = true; + AddTvDBId = true; TitleMainOverride = false; TitleMainList = [ TitleProvider.Shoko_Default, diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 90a38b4f..5053c40a 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -301,8 +301,7 @@ async function defaultSubmit(form) { ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); // Provider settings - config.AddAniDBId = form.querySelector("#AddAniDBId").checked; - config.AddTMDBId = form.querySelector("#AddTMDBId").checked; + config.ThirdPartyIdProviderList = retrieveSimpleList(form, "ThirdPartyIdProviderList"); // Library settings config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; @@ -510,8 +509,7 @@ async function syncSettings(form) { ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableList(form, "ProductionLocationList")); // Provider settings - config.AddAniDBId = form.querySelector("#AddAniDBId").checked; - config.AddTMDBId = form.querySelector("#AddTMDBId").checked; + config.ThirdPartyIdProviderList = retrieveSimpleList(form, "ThirdPartyIdProviderList"); // Library settings config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; @@ -970,8 +968,7 @@ export default function (page) { initSortableList(form, "ProductionLocationList", config.ProductionLocationList, config.ProductionLocationOrder); // Provider settings - form.querySelector("#AddAniDBId").checked = config.AddAniDBId; - form.querySelector("#AddTMDBId").checked = config.AddTMDBId; + initSimpleList(form, "ThirdPartyIdProviderList", config.ThirdPartyIdProviderList.map(s => s.trim()).filter(s => s)); // Library settings form.querySelector("#AutoMergeVersions").checked = config.AutoMergeVersions || false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 5375409d..145eb0e4 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -133,7 +133,7 @@ <h3 class="listItemBodyText">AniDB | Follow metadata language in library</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB | Use the language from the country of origin</h3> + <h3 class="listItemBodyText">AniDB | Use the language from the media's country of origin</h3> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> @@ -169,7 +169,7 @@ <h3 class="listItemBodyText">TMDB | Follow metadata language in library</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TMDB | Use the language from the country of origin</h3> + <h3 class="listItemBodyText">TMDB | Use the language from the media's country of origin</h3> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> @@ -232,7 +232,7 @@ <h3 class="listItemBodyText">AniDB | Follow metadata language in library</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB | Use the language from the country of origin</h3> + <h3 class="listItemBodyText">AniDB | Use the language from the media's country of origin</h3> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> @@ -268,7 +268,7 @@ <h3 class="listItemBodyText">TMDB | Follow metadata language in library</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TMDB | Use the language from the country of origin</h3> + <h3 class="listItemBodyText">TMDB | Use the language from the media's country of origin</h3> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> @@ -997,25 +997,25 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <div id="ContentRatingList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced content rating sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem sortableOption" data-option="AniDB"> + <div class="listItem sortableOption" data-option="TMDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB</h3> + <h3 class="listItemBodyText">TMDB | Follow country/region set in library</h3> </div> <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> </button> </div> - <div class="listItem sortableOption" data-option="TMDB"> + <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TMDB</h3> + <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h3> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> @@ -1063,19 +1063,38 @@ <h3 class="listItemBodyText">TMDB</h3> </div> <div class="fieldDescription">The metadata providers to use as the source of production locations, in priority order.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="AddAniDBId" /> - <span>Add AniDB IDs</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the AniDB ID for all supported item types where an ID is available.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="AddTMDBId" /> - <span>Add TMDB IDs</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the TMDB ID for all supported item types where an ID is available.</div> + <div id="ThirdPartyIdProviderList" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Add Third Party IDs:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">AniDB</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TMDB</h3> + </div> + </div> + <div class="listItem"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="TvDB"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">TvDB</h3> + </div> + </div> + </div> + <div class="fieldDescription">Enable which third party IDs to provide for other plugins to consume with supported media items.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1403,7 +1422,7 @@ <h3>SignalR Settings</h3> <div class="fieldDescription">A comma separated list of intervals in seconds to try re-establish the connection if the plugin gets disconnected from Shoko Server.</div> </div> <div id="SignalREventSources" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">SignalR Event Sources</h3> + <h3 class="checkboxListLabel">Event Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -1455,7 +1474,7 @@ <h3 class="listItemBodyText">TMDB</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SignalRDefaultRefreshEvents" /> - <span>Metadata Update Events</span> + <span>Metadata Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> <div>Enable the SignalR metadata update events for any new media folders.</div> From 96066d114ce5c934655230066eae9b312f0855c5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 03:25:44 +0200 Subject: [PATCH 052/144] fix: don't append list values to existing list - Don't append list values to the existing list, but instead replace the entire list with a new list, making sure the default option is also included. --- Shokofin/Configuration/configController.js | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 5053c40a..32a3a08e 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -563,10 +563,10 @@ async function removeMediaFolder(form) { const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); form.querySelector("#MediaFolderSelector").value = ""; - form.querySelector("#MediaFolderSelector").innerHTML += config.MediaFolders + form.querySelector("#MediaFolderSelector").innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); - form.querySelector("#SignalRMediaFolderSelector").innerHTML += config.MediaFolders + form.querySelector("#SignalRMediaFolderSelector").innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); @@ -999,7 +999,7 @@ export default function (page) { form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; - mediaFolderSelector.innerHTML += config.MediaFolders + mediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); @@ -1007,14 +1007,14 @@ export default function (page) { form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); - signalrMediaFolderSelector.innerHTML += config.MediaFolders + signalrMediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; // User settings - userSelector.innerHTML += users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); + userSelector.innerHTML = `<option value="">Click here to select a user</option>` + users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); // Experimental settings form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; From 541e5bf70d8aa765e50dd0cfb80f6185dd7f9700 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 04:24:04 +0200 Subject: [PATCH 053/144] chore: update settings page (take 2) - Readjusted defaults for 3rd party provider IDs. - Added shoko as a description source you can toggle. --- Shokofin/Configuration/PluginConfiguration.cs | 11 ++++------- Shokofin/Configuration/configPage.html | 17 +++++++++++++++-- 2 files changed, 19 insertions(+), 9 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index baea5053..338d06e3 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -533,8 +533,8 @@ public PluginConfiguration() SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; - AddTMDBId = true; - AddTvDBId = true; + AddTMDBId = false; + AddTvDBId = false; TitleMainOverride = false; TitleMainList = [ TitleProvider.Shoko_Default, @@ -556,15 +556,12 @@ public PluginConfiguration() TitleAllowAny = false; DescriptionSourceOverride = false; DescriptionSourceList = [ + DescriptionProvider.Shoko, DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB, ]; - DescriptionSourceOrder = [ - DescriptionProvider.AniDB, - DescriptionProvider.TvDB, - DescriptionProvider.TMDB, - ]; + DescriptionSourceOrder = [.. DescriptionSourceList]; VFS_Enabled = CanCreateSymbolicLinks; VFS_Threads = 4; VFS_AddReleaseGroup = false; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 145eb0e4..50b74044 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -289,6 +289,19 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of <div id="DescriptionSourceList" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> + <div class="listItem sortableOption" data-option="Shoko"> + <label class="listItemCheckboxContainer"> + <input is="emby-checkbox" type="checkbox" data-option="Shoko"> + <span></span> + </label> + <div class="listItemBody"> + <h3 class="listItemBodyText">Shoko</h3> + <span></span> + </div> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + </button> + </div> <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> <input is="emby-checkbox" type="checkbox" data-option="AniDB"> @@ -298,8 +311,8 @@ <h3 class="checkboxListLabel">Advanced description source:</h3> <h3 class="listItemBodyText">AniDB</h3> <span></span> </div> - <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> - <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> </button> </div> <div class="listItem sortableOption" data-option="TvDB"> From bbe1c6dd8a68d81506446e7d565c98575e687a06 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 04:24:48 +0200 Subject: [PATCH 054/144] feat: add expert mode toggle --- Shokofin/Configuration/PluginConfiguration.cs | 11 ++++ Shokofin/Configuration/configController.js | 44 ++++++++++++- Shokofin/Configuration/configPage.html | 63 ++++++++++--------- 3 files changed, 85 insertions(+), 33 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 338d06e3..5a5a36a5 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -494,6 +494,16 @@ public DescriptionProvider[] ThirdPartyIdProviderList #endregion + #region Expert Mode + + /// <summary> + /// Enable expert mode. + /// </summary> + [XmlElement("EXPERT_MODE")] + public bool ExpertMode { get; set; } + + #endregion + public PluginConfiguration() { Url = "http://127.0.0.1:8111"; @@ -594,5 +604,6 @@ public PluginConfiguration() EXPERIMENTAL_MergeSeasons = false; EXPERIMENTAL_MergeSeasonsTypes = [SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA]; EXPERIMENTAL_MergeSeasonsMergeWindowInDays = 185; + ExpertMode = false; } } diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 32a3a08e..36c0ed79 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -3,6 +3,9 @@ const PluginConfig = { }; const Messages = { + ExpertModeCountdown: "Press <count> more times to <toggle> expert mode.", + ExpertModeEnabled: "Expert mode enabled.", + ExpertModeDisabled: "Expert mode disabled.", ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", InvalidCredentials: "An error occurred while trying to authenticating the user using the provided credentials.", UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it.", @@ -631,6 +634,17 @@ async function syncSignalrSettings(form) { return config; } +async function toggleExpertMode(value) { + const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + + config.ExpertMode = value; + + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + Dashboard.processPluginConfigurationUpdateResult(result); + + return config; +} + async function syncUserSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); const userId = form.querySelector("#UserSelector").value; @@ -676,14 +690,24 @@ async function syncUserSettings(form) { } export default function (page) { + const MaxDebugPresses = 7; + let expertPresses = 0; + let expertMode = false; /** @type {HTMLFormElement} */ const form = page.querySelector("#ShokoConfigForm"); + const serverVersion = form.querySelector("#ServerVersion"); const userSelector = form.querySelector("#UserSelector"); const mediaFolderSelector = form.querySelector("#MediaFolderSelector"); const signalrMediaFolderSelector = form.querySelector("#SignalRMediaFolderSelector"); // Refresh the view after we changed the settings, so the view reflect the new settings. const refreshSettings = (config) => { + if (config.ExpertMode) { + form.classList.add("expert-mode"); + } + else { + form.classList.remove("expert-mode"); + } if (config.ServerVersion) { let version = `Version ${config.ServerVersion.Version}`; const extraDetails = [ @@ -693,10 +717,10 @@ export default function (page) { ].filter(s => s).join(", "); if (extraDetails) version += ` (${extraDetails})`; - form.querySelector("#ServerVersion").value = version; + serverVersion.value = version; } else { - form.querySelector("#ServerVersion").value = "Version N/A"; + serverVersion.value = "Version N/A"; } if (!config.CanCreateSymbolicLinks) { form.querySelector("#WindowsSymLinkWarning1").removeAttribute("hidden"); @@ -767,6 +791,19 @@ export default function (page) { Dashboard.hideLoadingMsg(); }; + serverVersion.addEventListener("click", async function () { + if (++expertPresses === MaxDebugPresses) { + expertPresses = 0; + expertMode = !expertMode; + Dashboard.alert(expertMode ? Messages.ExpertModeEnabled : Messages.ExpertModeDisabled); + const config = await toggleExpertMode(expertMode); + refreshSettings(config); + return; + } + if (expertPresses >= 3) + Dashboard.alert(Messages.ExpertModeCountdown.replace("<count>", MaxDebugPresses - expertPresses).replace("<toggle>", expertMode ? "disable" : "enable")); + }); + userSelector.addEventListener("change", function () { loadUserConfig(page, this.value); }); @@ -874,6 +911,9 @@ export default function (page) { const signalrStatus = await getSignalrStatus(); const users = await ApiClient.getUsers(); + expertPresses = 0; + expertMode = config.ExpertMode; + // Connection settings form.querySelector("#Url").value = config.Url; form.querySelector("#PublicUrl").value = config.PublicUrl; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 50b74044..0218aeef 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1,6 +1,7 @@ <div id="ShokoConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/ShokoController.js"> <div data-role="content"> <div class="content-primary"> + <style>form:not(.expert-mode) .expert-only { display: none !important; }</style> <form id="ShokoConfigForm"> <div class="verticalSection verticalSection-extrabottompadding"> <div class="sectionTitleContainer flex align-items-center"> @@ -35,7 +36,7 @@ <h3>Connection Settings</h3> </div> <div id="ConnectionResetContainer" hidden> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="ServerVersion" label="Server Version:" disabled readonly value="Unknown Version" /> + <input is="emby-input" type="text" id="ServerVersion" label="Server Version:" readonly value="Unknown Version" /> <div class="fieldDescription">The version of Shoko Server we're connected to.</div> </div> <button is="emby-button" type="submit" name="reset-connection" class="raised block emby-button"> @@ -58,28 +59,28 @@ <h3>Metadata Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleAllowAny" /> <span>Allow any title in selected language</span> </label> <div class="fieldDescription checkboxFieldDescription">Will add any title in the selected language if no official title is found.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> <span>Cleanup AniDB overviews</span> </label> <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> <span>Ignore unverified tags.</span> </label> <div class="fieldDescription checkboxFieldDescription">Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleMainOverride" /> <span>Override main title</span> @@ -88,7 +89,7 @@ <h3>Metadata Settings</h3> Enables the advanced selector for the main title selection. </div> </div> - <div id="TitleMainList" style="margin-bottom: 2em;"> + <div id="TitleMainList" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced main title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> @@ -178,7 +179,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </div> <div class="fieldDescription">The metadata providers to use as the source of the main title, in priority order.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleAlternateOverride" /> <span>Override alternate title</span> @@ -187,7 +188,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of Enables the advanced selector for the alternate title selection. </div> </div> - <div id="TitleAlternateList" style="margin-bottom: 2em;"> + <div id="TitleAlternateList" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced alternate title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> @@ -277,7 +278,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </div> <div class="fieldDescription">The metadata providers to use as the source of the alternate title, in priority order.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="DescriptionSourceOverride" /> <span>Override description source</span> @@ -286,7 +287,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of Enables the advanced selector for description source selection. </div> </div> - <div id="DescriptionSourceList" style="margin-bottom: 2em;"> + <div id="DescriptionSourceList" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="Shoko"> @@ -342,7 +343,7 @@ <h3 class="listItemBodyText">TMDB</h3> </div> <div class="fieldDescription">The metadata providers to use as the source of descriptions, in priority order.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TagOverride" /> <span>Override tag sources</span> @@ -351,7 +352,7 @@ <h3 class="listItemBodyText">TMDB</h3> Enables the advanced selector for tag source selection. </div> </div> - <div id="TagSources" style="margin-bottom: 2em;"> + <div id="TagSources" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced tag sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -582,7 +583,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> </div> <div class="fieldDescription">The tag sources to use as the source of tags.</div> </div> - <div id="TagIncludeFilters" style="margin-bottom: 2em;"> + <div id="TagIncludeFilters" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced tag include filters:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -651,7 +652,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> </div> <div class="fieldDescription">The type of tags to include for tags.</div> </div> - <div id="TagMinimumWeightContainer" class="selectContainer selectContainer-withDescription"> + <div id="TagMinimumWeightContainer" class="selectContainer selectContainer-withDescription expert-only"> <label class="selectLabel" for="TagMinimumWeight">Advanced minimum tag weight for tags:</label> <select is="emby-select" id="TagMinimumWeight" name="TagMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless" selected>Disabled (Default)</option> @@ -666,11 +667,11 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. </div> </div> - <div id="TagMaximumDepthContainer" class="inputContainer inputContainer-withDescription"> + <div id="TagMaximumDepthContainer" class="inputContainer inputContainer-withDescription expert-only"> <input is="emby-input" id="TagMaximumDepth" label="Maximum depth to add per tag source:" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for tags.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="GenreOverride" /> <span>Override genre sources</span> @@ -679,7 +680,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> Enables the advanced selector for genre source selection. </div> </div> - <div id="GenreSources" style="margin-bottom: 2em;"> + <div id="GenreSources" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced genre sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -910,7 +911,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> </div> <div class="fieldDescription">The tag sources to use as the source of genres.</div> </div> - <div id="GenreIncludeFilters" style="margin-bottom: 2em;"> + <div id="GenreIncludeFilters" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced genre include filters:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -979,7 +980,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> </div> <div class="fieldDescription">The type of tags to include for genres.</div> </div> - <div id="GenreMinimumWeightContainer" class="selectContainer selectContainer-withDescription"> + <div id="GenreMinimumWeightContainer" class="selectContainer selectContainer-withDescription expert-only"> <label class="selectLabel" for="GenreMinimumWeight">Advanced minimum tag weight for genres:</label> <select is="emby-select" id="GenreMinimumWeight" name="GenreMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless">Disabled</option> @@ -994,11 +995,11 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. </div> </div> - <div id="GenreMaximumDepthContainer" class="inputContainer inputContainer-withDescription"> + <div id="GenreMaximumDepthContainer" class="inputContainer inputContainer-withDescription expert-only"> <input is="emby-input" id="GenreMaximumDepth" label="Maximum depth to add per genre source:" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for genres.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="ContentRatingOverride" /> <span>Override content rating sources</span> @@ -1007,7 +1008,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> Enables the advanced selector for content rating source selection. </div> </div> - <div id="ContentRatingList" style="margin-bottom: 2em;"> + <div id="ContentRatingList" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced content rating sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="TMDB"> @@ -1037,7 +1038,7 @@ <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h </div> <div class="fieldDescription">The metadata providers to use as the source of content ratings, in priority order.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="ProductionLocationOverride" /> <span>Override production location sources</span> @@ -1046,7 +1047,7 @@ <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h Enables the advanced selector for production location source selection. </div> </div> - <div id="ProductionLocationList" style="margin-bottom: 2em;"> + <div id="ProductionLocationList" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced production location sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="AniDB"> @@ -1171,7 +1172,7 @@ <h3>Library Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">This filters out movies from the shows in your library. Disable this if you want your movies to show up as episodes within seasons of your shows instead.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="FilterMovieLibraries" /> <span>Filter movie libraries</span> @@ -1222,14 +1223,14 @@ <h3>Library Settings</h3> </details> </div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="CollectionMinSizeOfTwo" /> <span>Require two entries for a collection</span> </label> <div class="fieldDescription checkboxFieldDescription">Add a minimum requirement of two entries with the same collection id before creating a collection for them.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> <span>Force movie special features</span> @@ -1275,7 +1276,7 @@ <h3>Media Folder Settings</h3> <div class="fieldDescription verticalSection-extrabottompadding"> Placeholder description. </div> - <div class="inputContainer inputContainer-withDescription"> + <div class="inputContainer inputContainer-withDescription expert-only"> <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> <div class="fieldDescription">A comma separated list of folder names which will be ignored during library filtering.</div> </div> @@ -1430,11 +1431,11 @@ <h3>SignalR Settings</h3> Automatically establish a SignalR connection to Shoko Server when Jellyfin starts. </div> </div> - <div class="inputContainer inputContainer-withDescription"> + <div class="inputContainer inputContainer-withDescription expert-only"> <input is="emby-input" type="text" id="SignalRAutoReconnectIntervals" label="Auto Reconnect Intervals:" /> <div class="fieldDescription">A comma separated list of intervals in seconds to try re-establish the connection if the plugin gets disconnected from Shoko Server.</div> </div> - <div id="SignalREventSources" style="margin-bottom: 2em;"> + <div id="SignalREventSources" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Event Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -1602,7 +1603,7 @@ <h3>User Settings</h3> </button> </div> </fieldset> - <fieldset id="ExperimentalSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="ExperimentalSection" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> <legend> <h3>Experimental Settings</h3> </legend> From dd56da0029a2d98689066e59350fea1ee5cf1c43 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 04:26:21 +0200 Subject: [PATCH 055/144] refactor: modernize scheduled tasks + more MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Modernized the scheduled tasks by updating to more modern syntax. - Added expert mode toggles to some scheduled tasks, since not all tasks are equal, and some require extra care when running and/or shouldn't be ran on a whim. 😔 --- Shokofin/Tasks/CleanupVirtualRootTask.cs | 31 ++++----- Shokofin/Tasks/ClearPluginCacheTask.cs | 27 +++----- Shokofin/Tasks/ExportUserDataTask.cs | 10 +-- Shokofin/Tasks/ImportUserDataTask.cs | 11 +--- Shokofin/Tasks/MergeEpisodesTask.cs | 33 +++++----- Shokofin/Tasks/MergeMoviesTask.cs | 29 ++++----- Shokofin/Tasks/MigrateEpisodeUserDataTask.cs | 67 ++++++++++---------- Shokofin/Tasks/PostScanTask.cs | 21 ++---- Shokofin/Tasks/ReconstructCollectionsTask.cs | 38 ++++------- Shokofin/Tasks/SplitEpisodesTask.cs | 34 +++++----- Shokofin/Tasks/SplitMoviesTask.cs | 32 ++++------ Shokofin/Tasks/SyncUserDataTask.cs | 15 ++--- Shokofin/Tasks/VersionCheckTask.cs | 46 ++++++-------- 13 files changed, 169 insertions(+), 225 deletions(-) diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index f5de8257..4c87e9d5 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -12,15 +12,15 @@ namespace Shokofin.Tasks; /// <summary> -/// Cleanup any old VFS roots leftover from an outdated install or failed removal of the roots. +/// Clean-up any old VFS roots leftover from an outdated install or failed removal of the roots. /// </summary> -public class CleanupVirtualRootTask : IScheduledTask, IConfigurableScheduledTask +public class CleanupVirtualRootTask(ILogger<CleanupVirtualRootTask> logger, IFileSystem fileSystem, LibraryScanWatcher scanWatcher) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> - public string Name => "Cleanup Virtual File System Roots"; + public string Name => "Clean-up Virtual File System Roots"; /// <inheritdoc /> - public string Description => "Cleanup any old VFS roots leftover from an outdated install or failed removal of the roots."; + public string Description => "Clean-up any old VFS roots leftover from an outdated install or failed removal of the roots."; /// <inheritdoc /> public string Category => "Shokofin"; @@ -29,29 +29,26 @@ public class CleanupVirtualRootTask : IScheduledTask, IConfigurableScheduledTask public string Key => "ShokoCleanupVirtualRoot"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> - public bool IsLogged => true; + public bool IsLogged => Plugin.Instance.Configuration.ExpertMode; - private readonly ILogger<CleanupVirtualRootTask> Logger; + private readonly ILogger<CleanupVirtualRootTask> Logger = logger; - private readonly IFileSystem FileSystem; + private readonly IFileSystem FileSystem = fileSystem; - private readonly LibraryScanWatcher ScanWatcher; - - public CleanupVirtualRootTask(ILogger<CleanupVirtualRootTask> logger, IFileSystem fileSystem, LibraryScanWatcher scanWatcher) - { - Logger = logger; - FileSystem = fileSystem; - ScanWatcher = scanWatcher; - } + private readonly LibraryScanWatcher ScanWatcher = scanWatcher; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => [ + new() { + Type = TaskTriggerInfo.TriggerStartup, + }, + ]; public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { diff --git a/Shokofin/Tasks/ClearPluginCacheTask.cs b/Shokofin/Tasks/ClearPluginCacheTask.cs index c67399f5..e6045f0f 100644 --- a/Shokofin/Tasks/ClearPluginCacheTask.cs +++ b/Shokofin/Tasks/ClearPluginCacheTask.cs @@ -11,7 +11,7 @@ namespace Shokofin.Tasks; /// <summary> /// Forcefully clear the plugin cache. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> -public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask +public class ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, VirtualFileSystemService vfsService) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Clear Plugin Cache"; @@ -26,35 +26,28 @@ public class ClearPluginCacheTask : IScheduledTask, IConfigurableScheduledTask public string Key => "ShokoClearPluginCache"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> - public bool IsEnabled => true; + public bool IsEnabled => Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsLogged => true; - private readonly ShokoAPIManager ApiManager; + private readonly ShokoAPIManager _apiManager = apiManager; - private readonly ShokoAPIClient ApiClient; + private readonly ShokoAPIClient _apiClient = apiClient; - private readonly VirtualFileSystemService VfsService; - - public ClearPluginCacheTask(ShokoAPIManager apiManager, ShokoAPIClient apiClient, VirtualFileSystemService vfsService) - { - ApiManager = apiManager; - ApiClient = apiClient; - VfsService = vfsService; - } + private readonly VirtualFileSystemService _vfsService = vfsService; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - ApiClient.Clear(); - ApiManager.Clear(); - VfsService.Clear(); + _apiClient.Clear(); + _apiManager.Clear(); + _vfsService.Clear(); return Task.CompletedTask; } } diff --git a/Shokofin/Tasks/ExportUserDataTask.cs b/Shokofin/Tasks/ExportUserDataTask.cs index 8bd16c56..8a12e014 100644 --- a/Shokofin/Tasks/ExportUserDataTask.cs +++ b/Shokofin/Tasks/ExportUserDataTask.cs @@ -7,7 +7,7 @@ namespace Shokofin.Tasks; -public class ExportUserDataTask : IScheduledTask, IConfigurableScheduledTask +public class ExportUserDataTask(UserDataSyncManager userSyncManager) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Export User Data"; @@ -30,14 +30,10 @@ public class ExportUserDataTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - private readonly UserDataSyncManager _userSyncManager; + private readonly UserDataSyncManager _userSyncManager = userSyncManager; - public ExportUserDataTask(UserDataSyncManager userSyncManager) - { - _userSyncManager = userSyncManager; - } public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { diff --git a/Shokofin/Tasks/ImportUserDataTask.cs b/Shokofin/Tasks/ImportUserDataTask.cs index aec36544..8a58d076 100644 --- a/Shokofin/Tasks/ImportUserDataTask.cs +++ b/Shokofin/Tasks/ImportUserDataTask.cs @@ -7,7 +7,7 @@ namespace Shokofin.Tasks; -public class ImportUserDataTask : IScheduledTask, IConfigurableScheduledTask +public class ImportUserDataTask(UserDataSyncManager userSyncManager) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Import User Data"; @@ -30,15 +30,10 @@ public class ImportUserDataTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - private readonly UserDataSyncManager _userSyncManager; - - public ImportUserDataTask(UserDataSyncManager userSyncManager) - { - _userSyncManager = userSyncManager; - } + private readonly UserDataSyncManager _userSyncManager = userSyncManager; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index 80fa6298..13c1d027 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -8,13 +8,20 @@ namespace Shokofin.Tasks; -public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask +/// <summary> +/// Merge all episode entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. +/// </summary> +public class MergeEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { + private readonly MergeVersionsManager _mergeVersionManager = userSyncManager; + + private readonly LibraryScanWatcher _libraryScanWatcher = libraryScanWatcher; + /// <inheritdoc /> public string Name => "Merge Episodes"; /// <inheritdoc /> - public string Description => "Merge all episode entries with the same Shoko Episode ID set."; + public string Description => "Merge all episode entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; @@ -23,34 +30,26 @@ public class MergeEpisodesTask : IScheduledTask, IConfigurableScheduledTask public string Key => "ShokoMergeEpisodes"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> - public bool IsEnabled => true; + public bool IsEnabled => Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsLogged => true; - private readonly MergeVersionsManager VersionsManager; - - private readonly LibraryScanWatcher LibraryScanWatcher; - - public MergeEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) - { - VersionsManager = userSyncManager; - LibraryScanWatcher = libraryScanWatcher; - } - + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; + /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - if (LibraryScanWatcher.IsScanRunning) + if (_libraryScanWatcher.IsScanRunning) return; using (Plugin.Instance.Tracker.Enter("Merge Episodes Task")) { - await VersionsManager.MergeAllEpisodes(progress, cancellationToken); + await _mergeVersionManager.MergeAllEpisodes(progress, cancellationToken); } } } diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index fbeff678..74443b6f 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -8,13 +8,20 @@ namespace Shokofin.Tasks; -public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask +/// <summary> +/// Merge all movie entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. <summary> +/// </summary> +public class MergeMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { + private readonly MergeVersionsManager VersionsManager = userSyncManager; + + private readonly LibraryScanWatcher LibraryScanWatcher = libraryScanWatcher; + /// <inheritdoc /> public string Name => "Merge Movies"; /// <inheritdoc /> - public string Description => "Merge all movie entries with the same Shoko Episode ID set."; + public string Description => "Merge all movie entries with the same Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; @@ -23,27 +30,19 @@ public class MergeMoviesTask : IScheduledTask, IConfigurableScheduledTask public string Key => "ShokoMergeMovies"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> - public bool IsEnabled => true; + public bool IsEnabled => Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsLogged => true; - private readonly MergeVersionsManager VersionsManager; - - private readonly LibraryScanWatcher LibraryScanWatcher; - - public MergeMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) - { - VersionsManager = userSyncManager; - LibraryScanWatcher = libraryScanWatcher; - } - + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; + /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { if (LibraryScanWatcher.IsScanRunning) diff --git a/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs b/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs index ce0b348c..495eb812 100644 --- a/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs +++ b/Shokofin/Tasks/MigrateEpisodeUserDataTask.cs @@ -17,8 +17,24 @@ namespace Shokofin.Tasks; -public class MigrateEpisodeUserDataTask : IScheduledTask, IConfigurableScheduledTask +/// <summary> +/// Migrate user watch data for episodes store in Jellyfin to the newest id namespace. +/// </summary> +public class MigrateEpisodeUserDataTask( + ILogger<MigrateEpisodeUserDataTask> logger, + IUserDataManager userDataManager, + IUserManager userManager, + ILibraryManager libraryManager +) : IScheduledTask, IConfigurableScheduledTask { + private readonly ILogger<MigrateEpisodeUserDataTask> _logger = logger; + + private readonly IUserDataManager _userDataManager = userDataManager; + + private readonly IUserManager _userManager = userManager; + + private readonly ILibraryManager _libraryManager = libraryManager; + /// <inheritdoc /> public string Name => "Migrate Episode User Watch Data"; @@ -40,36 +56,17 @@ public class MigrateEpisodeUserDataTask : IScheduledTask, IConfigurableScheduled /// <inheritdoc /> public bool IsLogged => true; - private readonly ILogger<MigrateEpisodeUserDataTask> Logger; - - private readonly IUserDataManager UserDataManager; - - private readonly IUserManager UserManager; - - private readonly ILibraryManager LibraryManager; - - public MigrateEpisodeUserDataTask( - ILogger<MigrateEpisodeUserDataTask> logger, - IUserDataManager userDataManager, - IUserManager userManager, - ILibraryManager libraryManager - ) - { - Logger = logger; - UserDataManager = userDataManager; - UserManager = userManager; - LibraryManager = libraryManager; - } - + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; + /// <inheritdoc /> public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { var foundEpisodeCount = 0; var seriesDict = new Dictionary<string, (Series series, List<Episode> episodes)>(); - var users = UserManager.Users.ToList(); - var allEpisodes = LibraryManager.GetItemList(new InternalItemsQuery { + var users = _userManager.Users.ToList(); + var allEpisodes = _libraryManager.GetItemList(new InternalItemsQuery { IncludeItemTypes = [BaseItemKind.Episode], HasAnyProviderId = new Dictionary<string, string> { { ShokoFileId.Name, string.Empty } }, IsFolder = false, @@ -82,7 +79,7 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat }) .OfType<Episode>() .ToList(); - Logger.LogDebug("Attempting to migrate user watch data across {EpisodeCount} episodes and {UserCount} users.", allEpisodes.Count, users.Count); + _logger.LogDebug("Attempting to migrate user watch data across {EpisodeCount} episodes and {UserCount} users.", allEpisodes.Count, users.Count); foreach (var episode in allEpisodes) { cancellationToken.ThrowIfCancellationRequested(); @@ -98,11 +95,11 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat foundEpisodeCount++; } - Logger.LogInformation("Found {SeriesCount} series and {EpisodeCount} episodes across {AllEpisodeCount} total episodes to search for user watch data to migrate.", seriesDict.Count, foundEpisodeCount, allEpisodes.Count); + _logger.LogInformation("Found {SeriesCount} series and {EpisodeCount} episodes across {AllEpisodeCount} total episodes to search for user watch data to migrate.", seriesDict.Count, foundEpisodeCount, allEpisodes.Count); var savedCount = 0; var numComplete = 0; var numTotal = foundEpisodeCount * users.Count; - var userDataDict = users.ToDictionary(user => user, user => (UserDataManager.GetAllUserData(user.Id).DistinctBy(data => data.Key).ToDictionary(data => data.Key), new List<UserItemData>())); + var userDataDict = users.ToDictionary(user => user, user => (_userDataManager.GetAllUserData(user.Id).DistinctBy(data => data.Key).ToDictionary(data => data.Key), new List<UserItemData>())); var userDataToRemove = new List<UserItemData>(); foreach (var (seriesId, (series, episodes)) in seriesDict) { cancellationToken.ThrowIfCancellationRequested(); @@ -117,7 +114,7 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat // 10.8 id format .Prepend($"INVALID-BUT-DO-NOT-TOUCH:{seriesId}") .ToList(); - Logger.LogTrace("Migrating user watch data for series {SeriesName}. (Series={SeriesId},Primary={PrimaryKey},Search={SearchKeys})", series.Name, seriesId, primaryKey, keysToSearch); + _logger.LogTrace("Migrating user watch data for series {SeriesName}. (Series={SeriesId},Primary={PrimaryKey},Search={SearchKeys})", series.Name, seriesId, primaryKey, keysToSearch); foreach (var episode in episodes) { cancellationToken.ThrowIfCancellationRequested(); @@ -127,15 +124,15 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat var suffix = episode.ParentIndexNumber!.Value.ToString("000", CultureInfo.InvariantCulture) + episode.IndexNumber!.Value.ToString("000", CultureInfo.InvariantCulture); var videoUserDataKeys = (episode as Video).GetUserDataKeys(); var episodeKeysToSearch = keysToSearch.Select(key => key + suffix).Prepend(primaryKey + suffix).Concat(videoUserDataKeys).ToList(); - Logger.LogTrace("Migrating user watch data for season {SeasonNumber}, episode {EpisodeNumber} - {EpisodeName}. (Series={SeriesId},File={FileId},Search={SearchKeys})", episode.ParentIndexNumber, episode.IndexNumber, episode.Name, seriesId, fileId, episodeKeysToSearch); + _logger.LogTrace("Migrating user watch data for season {SeasonNumber}, episode {EpisodeNumber} - {EpisodeName}. (Series={SeriesId},File={FileId},Search={SearchKeys})", episode.ParentIndexNumber, episode.IndexNumber, episode.Name, seriesId, fileId, episodeKeysToSearch); foreach (var (user, (dataDict, dataList)) in userDataDict) { - var userData = UserDataManager.GetUserData(user, episode); + var userData = _userDataManager.GetUserData(user, episode); foreach (var searchKey in episodeKeysToSearch) { if (!dataDict.TryGetValue(searchKey, out var searchUserData)) continue; if (userData.CopyFrom(searchUserData)) { - Logger.LogInformation("Found user data to migrate. (Series={SeriesId},File={FileId},Search={SearchKeys},Key={SearchKey},User={UserId})", seriesId, fileId, episodeKeysToSearch, searchKey, user.Id); + _logger.LogInformation("Found user data to migrate. (Series={SeriesId},File={FileId},Search={SearchKeys},Key={SearchKey},User={UserId})", seriesId, fileId, episodeKeysToSearch, searchKey, user.Id); dataList.Add(userData); savedCount++; } @@ -154,14 +151,14 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat // Last attempt to cancel before we save all the changes. cancellationToken.ThrowIfCancellationRequested(); - Logger.LogDebug("Saving {UserDataCount} user watch data entries across {UserCount} users", savedCount, users.Count); + _logger.LogDebug("Saving {UserDataCount} user watch data entries across {UserCount} users", savedCount, users.Count); foreach (var (user, (dataDict, dataList)) in userDataDict) { if (dataList.Count is 0) continue; - UserDataManager.SaveAllUserData(user.Id, dataList.ToArray(), CancellationToken.None); + _userDataManager.SaveAllUserData(user.Id, dataList.ToArray(), CancellationToken.None); } - Logger.LogInformation("Saved {UserDataCount} user watch data entries across {UserCount} users", savedCount, users.Count); + _logger.LogInformation("Saved {UserDataCount} user watch data entries across {UserCount} users", savedCount, users.Count); progress.Report(100); return Task.CompletedTask; diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index 2112649a..cc8db7d8 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -2,25 +2,18 @@ using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Library; -using Shokofin.API; using Shokofin.Collections; using Shokofin.MergeVersions; -using Shokofin.Resolvers; namespace Shokofin.Tasks; -public class PostScanTask : ILibraryPostScanTask +public class PostScanTask(MergeVersionsManager versionsManager, CollectionManager collectionManager) : ILibraryPostScanTask { - private readonly MergeVersionsManager VersionsManager; + private readonly MergeVersionsManager _mergeVersionsManager = versionsManager; - private readonly CollectionManager CollectionManager; - - public PostScanTask(MergeVersionsManager versionsManager, CollectionManager collectionManager) - { - VersionsManager = versionsManager; - CollectionManager = collectionManager; - } + private readonly CollectionManager _collectionManager = collectionManager; + /// <inheritdoc /> public async Task Run(IProgress<double> progress, CancellationToken token) { // Merge versions now if the setting is enabled. @@ -30,17 +23,17 @@ public async Task Run(IProgress<double> progress, CancellationToken token) var simpleProgress = new Progress<double>(value => progress.Report(baseProgress + (value / 2d))); // Merge versions. - await VersionsManager.MergeAll(simpleProgress, token); + await _mergeVersionsManager.MergeAll(simpleProgress, token); // Reconstruct collections. baseProgress = 50; - await CollectionManager.ReconstructCollections(simpleProgress, token); + await _collectionManager.ReconstructCollections(simpleProgress, token); progress.Report(100d); } else { // Reconstruct collections. - await CollectionManager.ReconstructCollections(progress, token); + await _collectionManager.ReconstructCollections(progress, token); } } } diff --git a/Shokofin/Tasks/ReconstructCollectionsTask.cs b/Shokofin/Tasks/ReconstructCollectionsTask.cs index 1b372802..3b1e949e 100644 --- a/Shokofin/Tasks/ReconstructCollectionsTask.cs +++ b/Shokofin/Tasks/ReconstructCollectionsTask.cs @@ -9,15 +9,19 @@ namespace Shokofin.Tasks; /// <summary> -/// Reconstruct all Shoko collections outside a Library Scan. +/// Reconstruct all Shoko collections outside a Library Scan. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> -public class ReconstructCollectionsTask : IScheduledTask, IConfigurableScheduledTask +public class ReconstructCollectionsTask(CollectionManager collectionManager, LibraryScanWatcher libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { + private readonly CollectionManager _collectionManager = collectionManager; + + private readonly LibraryScanWatcher _libraryScanWatcher = libraryScanWatcher; + /// <inheritdoc /> public string Name => "Reconstruct Collections"; /// <inheritdoc /> - public string Description => "Reconstruct all Shoko collections outside a Library Scan."; + public string Description => "Reconstruct all Shoko collections outside a Library Scan. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; @@ -26,40 +30,26 @@ public class ReconstructCollectionsTask : IScheduledTask, IConfigurableScheduled public string Key => "ShokoReconstructCollections"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> - public bool IsEnabled => true; + public bool IsEnabled => Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsLogged => true; - private readonly CollectionManager CollectionManager; - - private readonly LibraryScanWatcher LibraryScanWatcher; - - public ReconstructCollectionsTask(CollectionManager collectionManager, LibraryScanWatcher libraryScanWatcher) - { - CollectionManager = collectionManager; - LibraryScanWatcher = libraryScanWatcher; - } - + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; - /// <summary> - /// Returns the task to be executed. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <param name="progress">The progress.</param> - /// <returns>Task.</returns> + /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - if (LibraryScanWatcher.IsScanRunning) + if (_libraryScanWatcher.IsScanRunning) return; using (Plugin.Instance.Tracker.Enter("Reconstruct Collections Task")) { - await CollectionManager.ReconstructCollections(progress, cancellationToken); + await _collectionManager.ReconstructCollections(progress, cancellationToken); } } } diff --git a/Shokofin/Tasks/SplitEpisodesTask.cs b/Shokofin/Tasks/SplitEpisodesTask.cs index ceb4dd83..32d6705e 100644 --- a/Shokofin/Tasks/SplitEpisodesTask.cs +++ b/Shokofin/Tasks/SplitEpisodesTask.cs @@ -8,14 +8,20 @@ namespace Shokofin.Tasks; -/// <summary -public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask +/// <summary> +/// Split all episode entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. +/// </summary> +public class SplitEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { + private readonly MergeVersionsManager _mergeVersionsManager = userSyncManager; + + private readonly LibraryScanWatcher _libraryScanWatcher = libraryScanWatcher; + /// <inheritdoc /> public string Name => "Split Episodes"; /// <inheritdoc /> - public string Description => "Split all episode entries with a Shoko Episode ID set."; + public string Description => "Split all episode entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; @@ -24,34 +30,26 @@ public class SplitEpisodesTask : IScheduledTask, IConfigurableScheduledTask public string Key => "ShokoSplitEpisodes"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> - public bool IsEnabled => true; + public bool IsEnabled => Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsLogged => true; - private readonly MergeVersionsManager VersionsManager; - - private readonly LibraryScanWatcher LibraryScanWatcher; - - public SplitEpisodesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) - { - VersionsManager = userSyncManager; - LibraryScanWatcher = libraryScanWatcher; - } - + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; + /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - if (LibraryScanWatcher.IsScanRunning) + if (_libraryScanWatcher.IsScanRunning) return; using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { - await VersionsManager.SplitAllEpisodes(progress, cancellationToken); + await _mergeVersionsManager.SplitAllEpisodes(progress, cancellationToken); } } } diff --git a/Shokofin/Tasks/SplitMoviesTask.cs b/Shokofin/Tasks/SplitMoviesTask.cs index 1aa82b77..ce18b115 100644 --- a/Shokofin/Tasks/SplitMoviesTask.cs +++ b/Shokofin/Tasks/SplitMoviesTask.cs @@ -9,15 +9,19 @@ namespace Shokofin.Tasks; /// <summary> -/// Class SplitMoviesTask. +/// Split all movie entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING. /// </summary> -public class SplitMoviesTask : IScheduledTask, IConfigurableScheduledTask +public class SplitMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) : IScheduledTask, IConfigurableScheduledTask { + private readonly MergeVersionsManager _mergeVersionsManager = userSyncManager; + + private readonly LibraryScanWatcher _libraryScanWatcher = libraryScanWatcher; + /// <inheritdoc /> public string Name => "Split Movies"; /// <inheritdoc /> - public string Description => "Split all movie entries with a Shoko Episode ID set."; + public string Description => "Split all movie entries with a Shoko Episode ID set. For debugging and troubleshooting. DO NOT RUN THIS TASK WHILE A LIBRARY SCAN IS RUNNING."; /// <inheritdoc /> public string Category => "Shokofin"; @@ -26,34 +30,26 @@ public class SplitMoviesTask : IScheduledTask, IConfigurableScheduledTask public string Key => "ShokoSplitMovies"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> - public bool IsEnabled => true; + public bool IsEnabled => Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsLogged => true; - private readonly MergeVersionsManager VersionsManager; - - private readonly LibraryScanWatcher LibraryScanWatcher; - - public SplitMoviesTask(MergeVersionsManager userSyncManager, LibraryScanWatcher libraryScanWatcher) - { - VersionsManager = userSyncManager; - LibraryScanWatcher = libraryScanWatcher; - } - + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; + /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { - if (LibraryScanWatcher.IsScanRunning) + if (_libraryScanWatcher.IsScanRunning) return; using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { - await VersionsManager.SplitAllMovies(progress, cancellationToken); + await _mergeVersionsManager.SplitAllMovies(progress, cancellationToken); } } } diff --git a/Shokofin/Tasks/SyncUserDataTask.cs b/Shokofin/Tasks/SyncUserDataTask.cs index ebc0cd06..27389fd9 100644 --- a/Shokofin/Tasks/SyncUserDataTask.cs +++ b/Shokofin/Tasks/SyncUserDataTask.cs @@ -7,8 +7,10 @@ namespace Shokofin.Tasks; -public class SyncUserDataTask : IScheduledTask, IConfigurableScheduledTask +public class SyncUserDataTask(UserDataSyncManager userSyncManager) : IScheduledTask, IConfigurableScheduledTask { + private readonly UserDataSyncManager _userSyncManager = userSyncManager; + /// <inheritdoc /> public string Name => "Sync User Data"; @@ -30,16 +32,11 @@ public class SyncUserDataTask : IScheduledTask, IConfigurableScheduledTask /// <inheritdoc /> public bool IsLogged => true; - private readonly UserDataSyncManager _userSyncManager; - - public SyncUserDataTask(UserDataSyncManager userSyncManager) - { - _userSyncManager = userSyncManager; - } - + /// <inheritdoc /> public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => Array.Empty<TaskTriggerInfo>(); + => []; + /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { await _userSyncManager.ScanAndSync(SyncDirection.Sync, progress, cancellationToken); diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs index b7a2675a..7be622c2 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -16,8 +16,14 @@ namespace Shokofin.Tasks; /// Responsible for updating the known version of the remote Shoko Server /// instance at startup and set intervals. /// </summary> -public class VersionCheckTask : IScheduledTask, IConfigurableScheduledTask +public class VersionCheckTask(ILogger<VersionCheckTask> logger, ILibraryManager libraryManager, ShokoAPIClient apiClient) : IScheduledTask, IConfigurableScheduledTask { + private readonly ILogger<VersionCheckTask> _logger = logger; + + private readonly ILibraryManager _libraryManager = libraryManager; + + private readonly ShokoAPIClient _apiClient = apiClient; + /// <inheritdoc /> public string Name => "Check Server Version"; @@ -31,43 +37,31 @@ public class VersionCheckTask : IScheduledTask, IConfigurableScheduledTask public string Key => "ShokoVersionCheck"; /// <inheritdoc /> - public bool IsHidden => false; + public bool IsHidden => !Plugin.Instance.Configuration.ExpertMode; /// <inheritdoc /> public bool IsEnabled => true; /// <inheritdoc /> - public bool IsLogged => true; - - private readonly ILogger<VersionCheckTask> Logger; - - private readonly ILibraryManager LibraryManager; - - private readonly ShokoAPIClient ApiClient; - - public VersionCheckTask(ILogger<VersionCheckTask> logger, ILibraryManager libraryManager, ShokoAPIClient apiClient) - { - Logger = logger; - LibraryManager = libraryManager; - ApiClient = apiClient; - } + public bool IsLogged => Plugin.Instance.Configuration.ExpertMode; public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - => new TaskTriggerInfo[1] { + => [ new() { Type = TaskTriggerInfo.TriggerStartup, }, - }; + ]; + /// <inheritdoc /> public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { var updated = false; - var version = await ApiClient.GetVersion(); + var version = await _apiClient.GetVersion(); if (version != null && ( Plugin.Instance.Configuration.ServerVersion == null || !string.Equals(version.ToString(), Plugin.Instance.Configuration.ServerVersion.ToString()) )) { - Logger.LogInformation("Found new Shoko Server version; {version}", version); + _logger.LogInformation("Found new Shoko Server version; {version}", version); Plugin.Instance.Configuration.ServerVersion = version; updated = true; } @@ -78,8 +72,8 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can mediaFolders .Select(m => m.ImportFolderId) .Distinct() - .Except(new int[1] { 0 }) - .Select(id => ApiClient.GetImportFolder(id)) + .Except([0]) + .Select(id => _apiClient.GetImportFolder(id)) .ToList() ) .ContinueWith(task => task.Result.OfType<ImportFolder>().ToDictionary(i => i.Id, i => i.Name)) @@ -88,16 +82,16 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can if (!importFolderNameMap.TryGetValue(mediaFolderConfig.ImportFolderId, out var importFolderName)) importFolderName = null; - if (mediaFolderConfig.LibraryId == Guid.Empty && LibraryManager.GetItemById(mediaFolderConfig.MediaFolderId) is Folder mediaFolder && - LibraryManager.GetVirtualFolders().FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path)) is { } library && + if (mediaFolderConfig.LibraryId == Guid.Empty && _libraryManager.GetItemById(mediaFolderConfig.MediaFolderId) is Folder mediaFolder && + _libraryManager.GetVirtualFolders().FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path)) is { } library && Guid.TryParse(library.ItemId, out var libraryId)) { - Logger.LogInformation("Found new library for media folder; {LibraryName} (Library={LibraryId},MediaFolder={MediaFolderPath})", library.Name, libraryId, mediaFolder.Path); + _logger.LogInformation("Found new library for media folder; {LibraryName} (Library={LibraryId},MediaFolder={MediaFolderPath})", library.Name, libraryId, mediaFolder.Path); mediaFolderConfig.LibraryId = libraryId; updated = true; } if (!string.Equals(mediaFolderConfig.ImportFolderName, importFolderName)) { - Logger.LogInformation("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolderConfig.ImportFolderId); + _logger.LogInformation("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolderConfig.ImportFolderId); mediaFolderConfig.ImportFolderName = importFolderName; updated = true; } From ecd133e535d9f847aaabb21ae19da863e06d91ad Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 05:02:09 +0200 Subject: [PATCH 056/144] chore: make usage tracker injectable --- Shokofin/Plugin.cs | 4 ++-- Shokofin/PluginServiceRegistrator.cs | 1 + Shokofin/Utils/UsageTracker.cs | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index f6c7a25b..a29c3e0e 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -130,13 +130,13 @@ public string BasePath /// </summary> public new event EventHandler<PluginConfiguration>? ConfigurationChanged; - public Plugin(ILoggerFactory loggerFactory, IServerConfigurationManager configurationManager, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) + public Plugin(UsageTracker usageTracker, IServerConfigurationManager configurationManager, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { _configurationManager = configurationManager; Instance = this; base.ConfigurationChanged += OnConfigChanged; VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); - Tracker = new(loggerFactory.CreateLogger<UsageTracker>(), TimeSpan.FromSeconds(60)); + Tracker = usageTracker; Logger = logger; CanCreateSymbolicLinks = true; if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { diff --git a/Shokofin/PluginServiceRegistrator.cs b/Shokofin/PluginServiceRegistrator.cs index 5cc3702d..d7877c3a 100644 --- a/Shokofin/PluginServiceRegistrator.cs +++ b/Shokofin/PluginServiceRegistrator.cs @@ -10,6 +10,7 @@ public class PluginServiceRegistrator : IPluginServiceRegistrator /// <inheritdoc /> public void RegisterServices(IServiceCollection serviceCollection, IServerApplicationHost applicationHost) { + serviceCollection.AddSingleton<Utils.UsageTracker>(); serviceCollection.AddSingleton<Utils.LibraryScanWatcher>(); serviceCollection.AddSingleton<API.ShokoAPIClient>(); serviceCollection.AddSingleton<API.ShokoAPIManager>(); diff --git a/Shokofin/Utils/UsageTracker.cs b/Shokofin/Utils/UsageTracker.cs index 3cf0a3b3..8c74262b 100644 --- a/Shokofin/Utils/UsageTracker.cs +++ b/Shokofin/Utils/UsageTracker.cs @@ -7,6 +7,8 @@ namespace Shokofin.Utils; public class UsageTracker { + private static readonly TimeSpan DefaultTimeout = TimeSpan.FromSeconds(60); + private readonly ILogger<UsageTracker> Logger; private readonly object LockObj = new(); @@ -19,11 +21,11 @@ public class UsageTracker public event EventHandler? Stalled; - public UsageTracker(ILogger<UsageTracker> logger, TimeSpan timeout) + public UsageTracker(ILogger<UsageTracker> logger) { Logger = logger; - Timeout = timeout; - StalledTimer = new(timeout.TotalMilliseconds) { + Timeout = DefaultTimeout; + StalledTimer = new(DefaultTimeout.TotalMilliseconds) { AutoReset = false, Enabled = false, }; From 6d0c4b3872438362d6cf8fd79d7c4c832985db79 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 05:12:55 +0200 Subject: [PATCH 057/144] feat: allow VFS to live in cache dir - Add a new **EXPERIMENTAL** option to place the VFS root directory in the cache directory instead of the config directory. Untested. Also haven't investigated if Jellyfin will auto-clear it yet or if it will leave it be. --- Shokofin/Configuration/PluginConfiguration.cs | 8 +++++- Shokofin/Configuration/configController.js | 3 +++ Shokofin/Configuration/configPage.html | 9 ++++++- Shokofin/Plugin.cs | 26 +++++++++++++++---- 4 files changed, 39 insertions(+), 7 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 5a5a36a5..d071e999 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -411,6 +411,11 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool VFS_AddResolution { get; set; } + /// <summary> + /// Places the VFS in the cache directory instead of the config directory. + /// </summary> + public bool VFS_LiveInCache { get; set; } + /// <summary> /// Enable/disable the filtering for new media-folders/libraries. /// </summary> @@ -572,10 +577,11 @@ public PluginConfiguration() DescriptionProvider.TMDB, ]; DescriptionSourceOrder = [.. DescriptionSourceList]; - VFS_Enabled = CanCreateSymbolicLinks; + VFS_Enabled = true; VFS_Threads = 4; VFS_AddReleaseGroup = false; VFS_AddResolution = false; + VFS_LiveInCache = false; AutoMergeVersions = true; UseGroupsForShows = false; SeparateMovies = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 36c0ed79..ef30a736 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -328,6 +328,7 @@ async function defaultSubmit(form) { form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; + config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; if (mediaFolderConfig) { const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { @@ -587,6 +588,7 @@ async function syncMediaFolderSettings(form) { form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; + config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; if (mediaFolderConfig) { const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { @@ -1037,6 +1039,7 @@ export default function (page) { form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; + form.querySelector("#VFS_LiveInCache").checked = config.VFS_LiveInCache; form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; mediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 0218aeef..e2e1059f 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1305,7 +1305,7 @@ <h3>Media Folder Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_Enabled" /> - <span>Virtual File System™</span> + <span>Virtual File System (<strong>VFS</strong>)</span> </label> <div class="fieldDescription checkboxFieldDescription"> <div>Enables the use of the Virtual File System™ for any new media libraries managed by the plugin.</div> @@ -1608,6 +1608,13 @@ <h3>User Settings</h3> <h3>Experimental Settings</h3> </legend> <div class="fieldDescription verticalSection-extrabottompadding">Any features/settings in this section is still considered to be in an experimental state. <strong>You can enable them, but at the risk if them messing up your library.</strong></div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_LiveInCache" /> + <span>Place VFS in cache</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will place the VFS in the cache directory instead of the config directory. You will need to manually move your VFS root if you plan to keep it when toggling this setting.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index a29c3e0e..e5d85350 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -123,7 +123,13 @@ public string BasePath /// <summary> /// "Virtual" File System Root Directory. /// </summary> - public readonly string VirtualRoot; + private string? _virtualRoot; + + /// <summary> + /// "Virtual" File System Root Directory. + /// </summary> + public string VirtualRoot => + _virtualRoot ??= Path.Join(Configuration.VFS_LiveInCache ? ApplicationPaths.CachePath : ApplicationPaths.ProgramDataPath, Name); /// <summary> /// Gets or sets the event handler that is triggered when this configuration changes. @@ -132,13 +138,15 @@ public string BasePath public Plugin(UsageTracker usageTracker, IServerConfigurationManager configurationManager, IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, ILogger<Plugin> logger) : base(applicationPaths, xmlSerializer) { + var configExists = File.Exists(ConfigurationFilePath); _configurationManager = configurationManager; - Instance = this; - base.ConfigurationChanged += OnConfigChanged; - VirtualRoot = Path.Join(applicationPaths.ProgramDataPath, "Shokofin", "VFS"); Tracker = usageTracker; Logger = logger; CanCreateSymbolicLinks = true; + Instance = this; + + base.ConfigurationChanged += OnConfigChanged; + if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) { var target = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestTarget.txt"); var link = Path.Join(Path.GetDirectoryName(VirtualRoot)!, "TestLink.txt"); @@ -158,10 +166,17 @@ public Plugin(UsageTracker usageTracker, IServerConfigurationManager configurati File.Delete(target); } } + IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); - Logger.LogDebug("Virtual File System Location; {Path}", VirtualRoot); + + Logger.LogDebug("Virtual File System Root Directory; {Path}", VirtualRoot); Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); + + if (!configExists && !CanCreateSymbolicLinks) { + Configuration.VFS_Enabled = false; + SaveConfiguration(); + } } public void UpdateConfiguration() @@ -175,6 +190,7 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) return; IgnoredFolders = config.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); + _virtualRoot = null; ConfigurationChanged?.Invoke(sender, config); } From ee373fc20ad1c4f8f4ac3681aeeea20bf20d7ee5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 05:13:52 +0200 Subject: [PATCH 058/144] misc: tweak log level of some tasks --- Shokofin/Tasks/CleanupVirtualRootTask.cs | 8 ++++---- Shokofin/Tasks/VersionCheckTask.cs | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index 4c87e9d5..379a63f7 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -63,17 +63,17 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat var vfsRoots = FileSystem.GetDirectories(Plugin.Instance.VirtualRoot, false) .ExceptBy(mediaFolders, directoryInfo => directoryInfo.Name) .ToList(); - Logger.LogInformation("Found {RemoveCount} VFS roots to remove.", vfsRoots.Count); + Logger.LogDebug("Found {RemoveCount} VFS roots to remove.", vfsRoots.Count); foreach (var vfsRoot in vfsRoots) { var folderStart = DateTime.Now; - Logger.LogInformation("Removing VFS root for {Id}.", vfsRoot.Name); + Logger.LogTrace("Removing VFS root for {Id}.", vfsRoot.Name); Directory.Delete(vfsRoot.FullName, true); var perFolderDeltaTime = DateTime.Now - folderStart; - Logger.LogInformation("Removed VFS root for {Id} in {TimeSpan}.", vfsRoot.Name, perFolderDeltaTime); + Logger.LogTrace("Removed VFS root for {Id} in {TimeSpan}.", vfsRoot.Name, perFolderDeltaTime); } var deltaTime = DateTime.Now - start; - Logger.LogInformation("Removed {RemoveCount} VFS roots in {TimeSpan}.", vfsRoots.Count, deltaTime); + Logger.LogDebug("Removed {RemoveCount} VFS roots in {TimeSpan}.", vfsRoots.Count, deltaTime); return Task.CompletedTask; } diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs index 7be622c2..b1b6eebc 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -61,7 +61,7 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can Plugin.Instance.Configuration.ServerVersion == null || !string.Equals(version.ToString(), Plugin.Instance.Configuration.ServerVersion.ToString()) )) { - _logger.LogInformation("Found new Shoko Server version; {version}", version); + _logger.LogDebug("Found new Shoko Server version; {version}", version); Plugin.Instance.Configuration.ServerVersion = version; updated = true; } @@ -85,13 +85,13 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can if (mediaFolderConfig.LibraryId == Guid.Empty && _libraryManager.GetItemById(mediaFolderConfig.MediaFolderId) is Folder mediaFolder && _libraryManager.GetVirtualFolders().FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path)) is { } library && Guid.TryParse(library.ItemId, out var libraryId)) { - _logger.LogInformation("Found new library for media folder; {LibraryName} (Library={LibraryId},MediaFolder={MediaFolderPath})", library.Name, libraryId, mediaFolder.Path); + _logger.LogDebug("Found new library for media folder; {LibraryName} (Library={LibraryId},MediaFolder={MediaFolderPath})", library.Name, libraryId, mediaFolder.Path); mediaFolderConfig.LibraryId = libraryId; updated = true; } if (!string.Equals(mediaFolderConfig.ImportFolderName, importFolderName)) { - _logger.LogInformation("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolderConfig.ImportFolderId); + _logger.LogDebug("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolderConfig.ImportFolderId); mediaFolderConfig.ImportFolderName = importFolderName; updated = true; } From 2c07b65c274e936122c265ca82fc4a14f1b4afa0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 05:36:28 +0200 Subject: [PATCH 059/144] fix: don't show 'settings saved' message - Don't show the 'Settings Saved' message when we sign-in and when we toggle expert mode. --- Shokofin/Configuration/configController.js | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index ef30a736..ecc3d823 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -439,8 +439,7 @@ async function defaultSubmit(form) { config.PublicUrl = publicUrl; form.querySelector("#Url").value = url; form.querySelector("#PublicUrl").value = publicUrl; - let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); + await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); } const username = form.querySelector("#Username").value; @@ -451,11 +450,12 @@ async function defaultSubmit(form) { config.Username = username; config.ApiKey = response.apikey; - let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); + Dashboard.hideLoadingMsg(); } catch (err) { + Dashboard.hideLoadingMsg(); Dashboard.alert(Messages.InvalidCredentials); console.error(err, Messages.InvalidCredentials); } @@ -473,9 +473,9 @@ async function resetConnectionSettings(form) { config.ApiKey = ""; config.ServerVersion = null; - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); + Dashboard.hideLoadingMsg(); return config; } @@ -549,7 +549,7 @@ async function unlinkUser(form) { config.UserList.splice(index, 1); } - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) + const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.processPluginConfigurationUpdateResult(result); return config; @@ -641,8 +641,9 @@ async function toggleExpertMode(value) { config.ExpertMode = value; - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); + await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); + + Dashboard.alert(value ? Messages.ExpertModeEnabled : Messages.ExpertModeDisabled); return config; } @@ -797,7 +798,6 @@ export default function (page) { if (++expertPresses === MaxDebugPresses) { expertPresses = 0; expertMode = !expertMode; - Dashboard.alert(expertMode ? Messages.ExpertModeEnabled : Messages.ExpertModeDisabled); const config = await toggleExpertMode(expertMode); refreshSettings(config); return; From 5f20870e66332179e5942cfc3a4d59fb41e86b7c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 05:40:02 +0200 Subject: [PATCH 060/144] fix: add connected/reset messages - Added a alert/toast for establishing and resetting the connection to Shoko. --- Shokofin/Configuration/configController.js | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index ecc3d823..1b084477 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -7,6 +7,8 @@ const Messages = { ExpertModeEnabled: "Expert mode enabled.", ExpertModeDisabled: "Expert mode disabled.", ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", + ConnectedToShoko: "Connection established.", + DisconnectedToShoko: "Connection reset.", InvalidCredentials: "An error occurred while trying to authenticating the user using the provided credentials.", UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it.", }; @@ -453,6 +455,7 @@ async function defaultSubmit(form) { await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.hideLoadingMsg(); + Dashboard.alert(Messages.ConnectedToShoko); } catch (err) { Dashboard.hideLoadingMsg(); @@ -476,6 +479,7 @@ async function resetConnectionSettings(form) { await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); Dashboard.hideLoadingMsg(); + Dashboard.alert(Messages.DisconnectedToShoko); return config; } From 18509fe38ecf5321ee0de599fb70d440f370dfb9 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 19 Sep 2024 21:36:41 +0200 Subject: [PATCH 061/144] fix: only add extras for movies if the movie will exist --- Shokofin/Resolvers/VirtualFileSystemService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index c9e54675..a00b4815 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -735,7 +735,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (isMovieSeason && collectionType is not CollectionType.tvshows) { if (extrasFolders != null) { foreach (var extrasFolder in extrasFolders) - foreach (var episodeInfo in season.EpisodeList) + foreach (var episodeInfo in season.EpisodeList.Where(a => a.Shoko.Size > 0)) folders.Add(Path.Join(vfsPath, $"{showName} [{ShokoSeriesId.Name}={show.Id}] [{ShokoEpisodeId.Name}={episodeInfo.Id}]", extrasFolder)); } else { From 715f740b7f7fa0dda7d7783afb319b0e4a0db361 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 20 Sep 2024 02:17:49 +0200 Subject: [PATCH 062/144] feat: add EXPERIMENTAL physical roots for VFS - Added a new EXPERIMENTAL feature to attach a physical VFS root as a media folder to each of your libraries, allowing us to mount the VFS to the new root and to re-use media folders across libraries without resorting to _other_ workarounds. --- Shokofin/CollectionTypeExtensions.cs | 22 ++ .../Configuration/MediaFolderConfiguration.cs | 7 + .../MediaFolderConfigurationService.cs | 229 ++++++++++++++---- Shokofin/Configuration/PluginConfiguration.cs | 7 + Shokofin/Configuration/configController.js | 33 ++- Shokofin/Configuration/configPage.html | 7 + Shokofin/Events/EventDispatchService.cs | 6 +- Shokofin/IdLookup.cs | 14 ++ Shokofin/Plugin.cs | 13 +- Shokofin/Resolvers/ShokoIgnoreRule.cs | 2 +- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 4 +- Shokofin/Resolvers/ShokoResolver.cs | 4 +- .../Resolvers/VirtualFileSystemService.cs | 16 +- 13 files changed, 285 insertions(+), 79 deletions(-) create mode 100644 Shokofin/CollectionTypeExtensions.cs diff --git a/Shokofin/CollectionTypeExtensions.cs b/Shokofin/CollectionTypeExtensions.cs new file mode 100644 index 00000000..0593aa26 --- /dev/null +++ b/Shokofin/CollectionTypeExtensions.cs @@ -0,0 +1,22 @@ + +using Jellyfin.Data.Enums; +using MediaBrowser.Model.Entities; + +namespace Shokofin; + +public static class CollectionTypeExtensions +{ + public static CollectionType? ConvertToCollectionType(this CollectionTypeOptions? collectionType) + => collectionType switch + { + CollectionTypeOptions.movies => CollectionType.movies, + CollectionTypeOptions.tvshows => CollectionType.tvshows, + CollectionTypeOptions.music => CollectionType.music, + CollectionTypeOptions.musicvideos => CollectionType.musicvideos, + CollectionTypeOptions.homevideos => CollectionType.homevideos, + CollectionTypeOptions.boxsets => CollectionType.boxsets, + CollectionTypeOptions.books => CollectionType.books, + CollectionTypeOptions.mixed => null, + null or _ => CollectionType.unknown, + }; +} \ No newline at end of file diff --git a/Shokofin/Configuration/MediaFolderConfiguration.cs b/Shokofin/Configuration/MediaFolderConfiguration.cs index 671b5b6c..83b58d19 100644 --- a/Shokofin/Configuration/MediaFolderConfiguration.cs +++ b/Shokofin/Configuration/MediaFolderConfiguration.cs @@ -51,6 +51,13 @@ public class MediaFolderConfiguration /// </summary> public string ImportFolderRelativePath { get; set; } = string.Empty; + /// <summary> + /// Indicates the Jellyfin Media Folder is a virtual file system folder. + /// </summary> + [XmlIgnore] + [JsonInclude] + public bool IsVirtualRoot => ImportFolderId < 0; + /// <summary> /// Indicates the Jellyfin Media Folder is mapped to a Shoko Import Folder. /// </summary> diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 9f232f88..062369e6 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -2,16 +2,19 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; using Emby.Naming.Common; using Jellyfin.Data.Enums; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.Configuration.Models; +using Shokofin.Utils; namespace Shokofin.Configuration; @@ -45,12 +48,20 @@ public class MediaFolderConfigurationService private readonly IDirectoryService DirectoryService; + private readonly LibraryScanWatcher LibraryScanWatcher; + + private readonly IIdLookup Lookup; + + private readonly UsageTracker UsageTracker; + private readonly ShokoAPIClient ApiClient; private readonly NamingOptions NamingOptions; private readonly Dictionary<Guid, string> MediaFolderChangeKeys = []; + private bool ShouldGenerateAllConfigurations = true; + private readonly object LockObj = new(); public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationAdded; @@ -58,12 +69,17 @@ public class MediaFolderConfigurationService public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationUpdated; public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationRemoved; + + public readonly Dictionary<Guid, (string libraryName, HashSet<string> add, HashSet<string> remove)> LibraryEdits = []; public MediaFolderConfigurationService( ILogger<MediaFolderConfigurationService> logger, ILibraryManager libraryManager, IFileSystem fileSystem, IDirectoryService directoryService, + LibraryScanWatcher libraryScanWatcher, + IIdLookup lookup, + UsageTracker usageTracker, ShokoAPIClient apiClient, NamingOptions namingOptions ) @@ -72,11 +88,16 @@ NamingOptions namingOptions LibraryManager = libraryManager; FileSystem = fileSystem; DirectoryService = directoryService; + LibraryScanWatcher = libraryScanWatcher; + Lookup = lookup; + UsageTracker = usageTracker; ApiClient = apiClient; NamingOptions = namingOptions; foreach (var mediaConfig in Plugin.Instance.Configuration.MediaFolders) MediaFolderChangeKeys[mediaConfig.MediaFolderId] = ConstructKey(mediaConfig); + UsageTracker.Stalled += OnUsageTrackerStalled; + LibraryScanWatcher.ValueChanged += OnLibraryScanValueChanged; LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; Plugin.Instance.ConfigurationChanged += OnConfigurationChanged; } @@ -85,11 +106,55 @@ NamingOptions namingOptions { LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; Plugin.Instance.ConfigurationChanged -= OnConfigurationChanged; + LibraryScanWatcher.ValueChanged -= OnLibraryScanValueChanged; + UsageTracker.Stalled -= OnUsageTrackerStalled; MediaFolderChangeKeys.Clear(); } #region Changes Tracking + private void OnLibraryScanValueChanged(object? sender, bool isRunning) + { + if (isRunning) + return; + + Task.Run(EditLibraries); + } + + private void OnUsageTrackerStalled(object? sender, EventArgs eventArgs) + { + Task.Run(EditLibraries); + } + + private void EditLibraries() + { + lock (LockObj) { + if (LibraryEdits.Count is 0) + return; + + ShouldGenerateAllConfigurations = true; + var libraryEdits = LibraryEdits.ToList(); + LibraryEdits.Clear(); + foreach (var (libraryId, (libraryName, add, remove)) in libraryEdits) { + foreach (var vfsPath in add) + { + // Before we add the media folder we need to + // a) make sure it exists so we can add it without Jellyfin throwing a fit, and + // b) make sure it's not empty to make sure Jellyfin doesn't skip resolving it. + if (!Directory.Exists(vfsPath)) + Directory.CreateDirectory(vfsPath); + if (!FileSystem.GetFileSystemEntryPaths(vfsPath).Any()) + File.WriteAllText(Path.Join(vfsPath, ".keep"), string.Empty); + + LibraryManager.AddMediaPath(libraryName, new(vfsPath)); + } + foreach (var vfsPath in remove) + LibraryManager.RemoveMediaPath(libraryName, new(vfsPath)); + } + LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); + } + } + private static string ConstructKey(MediaFolderConfiguration config) => $"IsMapped={config.IsMapped},IsFileEventsEnabled={config.IsFileEventsEnabled},IsRefreshEventsEnabled={config.IsRefreshEventsEnabled},IsVirtualFileSystemEnabled={config.IsVirtualFileSystemEnabled},LibraryFilteringMode={config.LibraryFilteringMode}"; @@ -138,7 +203,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) lock (LockObj) { var virtualFolders = LibraryManager.GetVirtualFolders(); return Plugin.Instance.Configuration.MediaFolders - .Where(config => config.IsMapped && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) + .Where(config => config.IsMapped && !config.IsVirtualRoot && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) .GroupBy(config => config.LibraryId) .Select(groupBy => ( libraryFolder: LibraryManager.GetItemById(groupBy.Key) as Folder, @@ -159,58 +224,110 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } } - public (string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, Func<MediaFolderConfiguration, bool>? filter = null) + public (string vfsPath, string mainMediaFolderPath, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, CollectionType? collectionType, Func<MediaFolderConfiguration, bool>? filter = null) { - var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder); + var attachRoot = Plugin.Instance.Configuration.VFS_AttachRoot; + var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder, collectionType); lock (LockObj) { if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) - return (string.Empty, string.Empty, null, []); + return (string.Empty, string.Empty, []); var virtualFolder = LibraryManager.GetVirtualFolders() .FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == mediaFolderConfig.LibraryId); if (virtualFolder is null || virtualFolder.Locations.Length is 0) - return (string.Empty, string.Empty, null, []); + return (string.Empty, string.Empty, []); + + var vfsPath = libraryFolder.GetVirtualRoot(); + var mediaFolders = Plugin.Instance.Configuration.MediaFolders + .Where(config => config.IsMapped && !config.IsVirtualRoot && config.LibraryId == mediaFolderConfig.LibraryId && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) + .ToList(); + if (attachRoot && mediaFolderConfig.IsVirtualFileSystemEnabled) + return (vfsPath, vfsPath, mediaFolders); + return ( libraryFolder.GetVirtualRoot(), virtualFolder.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty, - LibraryManager.GetConfiguredContentType(libraryFolder), - Plugin.Instance.Configuration.MediaFolders - .Where(config => config.IsMapped && config.LibraryId == mediaFolderConfig.LibraryId && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) - .ToList() + mediaFolders ); } } - public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder mediaFolder) + public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder mediaFolder, CollectionType? collectionType = CollectionType.unknown) { - if (LibraryManager.GetVirtualFolders().FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path)) is not { } library || !Guid.TryParse(library.ItemId, out var libraryId)) - throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); - lock (LockObj) { + var allVirtualFolders = LibraryManager.GetVirtualFolders(); + if (allVirtualFolders.FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path) && (collectionType is CollectionType.unknown || p.CollectionType.ConvertToCollectionType() == collectionType)) is not { } library || !Guid.TryParse(library.ItemId, out var libraryId)) + throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); + var config = Plugin.Instance.Configuration; + var attachRoot = config.VFS_AttachRoot; var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); - var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id) ?? + var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id && c.LibraryId == libraryId) ?? CreateConfigurationForPath(libraryId, mediaFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); - // Map all the other media folders now… since we need them to exist when generating the VFS. - foreach (var mediaFolderPath in library.Locations) { - if (string.Equals(mediaFolderPath, mediaFolder.Path)) - continue; + GenerateAllConfigurations(allVirtualFolders); - if (config.MediaFolders.Find(c => string.Equals(mediaFolderPath, c.MediaFolderPath)) is {} mfc) - continue; + return mediaFolderConfig; + } + } + private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders) + { + if (!ShouldGenerateAllConfigurations) + return; + ShouldGenerateAllConfigurations = false; + + var filteredVirtualFolders = allVirtualFolders + .Where(virtualFolder => + virtualFolder.CollectionType.ConvertToCollectionType() is null or CollectionType.movies or CollectionType.tvshows && + Lookup.IsEnabledForLibraryOptions(virtualFolder.LibraryOptions, out _) + ) + .ToList(); + var config = Plugin.Instance.Configuration; + var attachRoot = config.VFS_AttachRoot; + foreach (var virtualFolder in filteredVirtualFolders) { + if (!Guid.TryParse(virtualFolder.ItemId, out var libraryId) || LibraryManager.GetItemById(libraryId) is not Folder libraryFolder) + throw new Exception($"Unable to find virtual folder \"{virtualFolder.Name}\""); + + MediaFolderConfiguration? mediaFolderConfig = null; + var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); + foreach (var mediaFolderPath in virtualFolder.Locations) { if (LibraryManager.FindByPath(mediaFolderPath, true) is not Folder secondFolder) { Logger.LogTrace("Unable to find database entry for {Path}", mediaFolderPath); continue; } - CreateConfigurationForPath(libraryId, secondFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); + if (config.MediaFolders.Find(c => string.Equals(mediaFolderPath, c.MediaFolderPath) && c.LibraryId == libraryId) is { } mfc) + { + mediaFolderConfig = mfc; + continue; + } + + mediaFolderConfig = CreateConfigurationForPath(libraryId, secondFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); } - return mediaFolderConfig; - } + if (!attachRoot || !(mediaFolderConfig?.IsVirtualFileSystemEnabled ?? false)) + continue; + var vfsPath = libraryFolder.GetVirtualRoot(); + if (!virtualFolder.Locations.Contains(vfsPath, Path.DirectorySeparatorChar is '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)) { + if (!LibraryEdits.TryGetValue(libraryId, out var edits)) + LibraryEdits[libraryId] = edits = (libraryFolder.Name, [], []); + edits.add.Add(vfsPath); + } + + var virtualRoot = Plugin.Instance.VirtualRoot; + var toRemove = virtualFolder.Locations + .Except([vfsPath]) + .Where(location => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + .ToList(); + if (toRemove.Count > 0) { + if (!LibraryEdits.TryGetValue(libraryId, out var edits)) + LibraryEdits[libraryId] = edits = (libraryFolder.Name, [], []); + foreach (var location in toRemove) + edits.remove.Add(location); + } + } } private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid libraryId, Folder mediaFolder, MediaFolderConfiguration? libraryConfig) @@ -229,39 +346,47 @@ private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid lib var start = DateTime.UtcNow; var attempts = 0; - var samplePaths = FileSystem.GetFilePaths(mediaFolder.Path, true) - .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) - .Take(100) - .ToList(); + if (mediaFolder.Path.StartsWith(Plugin.Instance.VirtualRoot)) { + Logger.LogDebug("Not asking remote server because {Path} is a VFS root.", mediaFolder.Path); + mediaFolderConfig.ImportFolderId = -1; + mediaFolderConfig.ImportFolderName = "VFS Root"; + mediaFolderConfig.ImportFolderRelativePath = string.Empty; + } + else { + var samplePaths = FileSystem.GetFilePaths(mediaFolder.Path, true) + .Where(path => NamingOptions.VideoFileExtensions.Contains(Path.GetExtension(path))) + .Take(100) + .ToList(); - Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}.", samplePaths.Count > 100 ? 100 : samplePaths.Count, mediaFolder.Path); - foreach (var path in samplePaths) { - attempts++; - var partialPath = path[mediaFolder.Path.Length..]; - var files = await ApiClient.GetFileByPath(partialPath).ConfigureAwait(false); - var file = files.FirstOrDefault(); - if (file is null) - continue; + Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}.", samplePaths.Count > 100 ? 100 : samplePaths.Count, mediaFolder.Path); + foreach (var path in samplePaths) { + attempts++; + var partialPath = path[mediaFolder.Path.Length..]; + var files = await ApiClient.GetFileByPath(partialPath).ConfigureAwait(false); + var file = files.FirstOrDefault(); + if (file is null) + continue; - var fileId = file.Id.ToString(); - var fileLocations = file.Locations - .Where(location => location.RelativePath.EndsWith(partialPath)) - .ToList(); - if (fileLocations.Count is 0) - continue; + var fileId = file.Id.ToString(); + var fileLocations = file.Locations + .Where(location => location.RelativePath.EndsWith(partialPath)) + .ToList(); + if (fileLocations.Count is 0) + continue; - var fileLocation = fileLocations[0]; - mediaFolderConfig.ImportFolderId = fileLocation.ImportFolderId; - mediaFolderConfig.ImportFolderRelativePath = fileLocation.RelativePath[..^partialPath.Length]; - break; - } + var fileLocation = fileLocations[0]; + mediaFolderConfig.ImportFolderId = fileLocation.ImportFolderId; + mediaFolderConfig.ImportFolderRelativePath = fileLocation.RelativePath[..^partialPath.Length]; + break; + } - try { - var importFolder = await ApiClient.GetImportFolder(mediaFolderConfig.ImportFolderId); - if (importFolder != null) - mediaFolderConfig.ImportFolderName = importFolder.Name; + try { + var importFolder = await ApiClient.GetImportFolder(mediaFolderConfig.ImportFolderId); + if (importFolder != null) + mediaFolderConfig.ImportFolderName = importFolder.Name; + } + catch { } } - catch { } // Store and log the result. MediaFolderChangeKeys[mediaFolder.Id] = ConstructKey(mediaFolderConfig); @@ -293,4 +418,4 @@ private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid lib } #endregion -} \ No newline at end of file +} diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index d071e999..0444429a 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -416,6 +416,12 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool VFS_LiveInCache { get; set; } + /// <summary> + /// Attach a physical VFS root as a media folder instead of attaching the + /// VFS children to one of the "normal" media folders. + /// </summary> + public bool VFS_AttachRoot { get; set; } + /// <summary> /// Enable/disable the filtering for new media-folders/libraries. /// </summary> @@ -582,6 +588,7 @@ public PluginConfiguration() VFS_AddReleaseGroup = false; VFS_AddResolution = false; VFS_LiveInCache = false; + VFS_AttachRoot = false; AutoMergeVersions = true; UseGroupsForShows = false; SeparateMovies = false; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 1b084477..2f45fbdc 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -331,6 +331,7 @@ async function defaultSubmit(form) { config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; + config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; if (mediaFolderConfig) { const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { @@ -535,6 +536,8 @@ async function syncSettings(form) { config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Experimental settings + config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; + config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); @@ -561,10 +564,10 @@ async function unlinkUser(form) { async function removeMediaFolder(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const mediaFolderId = form.querySelector("#MediaFolderSelector").value; - if (!mediaFolderId) return; + const [mediaFolderId, libraryId] = form.querySelector("#MediaFolderSelector").value.value.split(","); + if (!mediaFolderId || !libraryId) return; - const index = config.MediaFolders.findIndex((m) => m.MediaFolderId === mediaFolderId); + const index = config.MediaFolders.findIndex((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId); if (index !== -1) { config.MediaFolders.splice(index, 1); } @@ -572,10 +575,12 @@ async function removeMediaFolder(form) { const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); form.querySelector("#MediaFolderSelector").value = ""; form.querySelector("#MediaFolderSelector").innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); form.querySelector("#SignalRMediaFolderSelector").innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); Dashboard.processPluginConfigurationUpdateResult(result); @@ -584,8 +589,8 @@ async function removeMediaFolder(form) { async function syncMediaFolderSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const mediaFolderId = form.querySelector("#MediaFolderSelector").value; - const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + const [mediaFolderId, libraryId] = form.querySelector("#MediaFolderSelector").value.split(","); + const mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); config.IgnoredFolders = ignoredFolders; @@ -593,8 +598,8 @@ async function syncMediaFolderSettings(form) { config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; + config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; if (mediaFolderConfig) { - const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; @@ -613,7 +618,7 @@ async function syncMediaFolderSettings(form) { async function syncSignalrSettings(form) { const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; + const [mediaFolderId, libraryId] = form.querySelector("#SignalRMediaFolderSelector").value.split(","); const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; @@ -621,9 +626,8 @@ async function syncSignalrSettings(form) { config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); - const mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + const mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; if (mediaFolderConfig) { - const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { c.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; c.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; @@ -1044,10 +1048,12 @@ export default function (page) { form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; form.querySelector("#VFS_LiveInCache").checked = config.VFS_LiveInCache; + form.querySelector("#VFS_AttachRoot").checked = config.VFS_AttachRoot; form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; mediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); // SignalR settings @@ -1055,7 +1061,8 @@ export default function (page) { form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); signalrMediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) + .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) + .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) .join(""); form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e2e1059f..e5639c79 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1615,6 +1615,13 @@ <h3>Experimental Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Enabling this will place the VFS in the cache directory instead of the config directory. You will need to manually move your VFS root if you plan to keep it when toggling this setting.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_AttachRoot" /> + <span>Attach physical root for VFS</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will attach a physical VFS "root" to your libraries as a media folder, allowing us to mount the VFS children to the new "root" and to re-use media folders across libraries without resorting to <i>other</i> workarounds.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index deeaad88..36f87a2c 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -463,7 +463,11 @@ private void RemoveSymbolicLink(string filePath) private async Task ReportMediaFolderChanged(Folder mediaFolder, string pathToReport) { - if (LibraryManager.GetLibraryOptions(mediaFolder) is not LibraryOptions libraryOptions || !libraryOptions.EnableRealtimeMonitor) { + // Don't block real-time file events on the media folder that uses a physical VFS root, or if real-time monitoring is disabled. + if (mediaFolder.Path.StartsWith(Plugin.Instance.VirtualRoot) || + LibraryManager.GetLibraryOptions(mediaFolder) is not LibraryOptions libraryOptions || + !libraryOptions.EnableRealtimeMonitor + ) { LibraryMonitor.ReportFileSystemChanged(pathToReport); return; } diff --git a/Shokofin/IdLookup.cs b/Shokofin/IdLookup.cs index dd0c37f0..89ee6704 100644 --- a/Shokofin/IdLookup.cs +++ b/Shokofin/IdLookup.cs @@ -5,6 +5,7 @@ using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; +using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using Shokofin.API; using Shokofin.ExternalIds; @@ -31,6 +32,14 @@ public interface IIdLookup /// <returns>True if the plugin is enabled for the <see cref="MediaBrowser.Controller.Entities.BaseItem" /></returns> bool IsEnabledForItem(BaseItem item, out bool isSoleProvider); + /// <summary> + /// Check if the plugin is enabled for <see cref="LibraryOptions" >the library options</see>. + /// </summary> + /// <param name="libraryOptions">The <see cref="LibraryOptions" /> to check.</param> + /// <param name="isSoleProvider">True if the plugin is the only metadata provider enabled for the item.</param> + /// <returns>True if the plugin is enabled for the <see cref="LibraryOptions" /></returns> + bool IsEnabledForLibraryOptions(LibraryOptions libraryOptions, out bool isSoleProvider); + #endregion #region Series Id @@ -119,6 +128,11 @@ public bool IsEnabledForItem(BaseItem item, out bool isSoleProvider) return false; } + return IsEnabledForLibraryOptions(libraryOptions, out isSoleProvider); + } + + public bool IsEnabledForLibraryOptions(LibraryOptions libraryOptions, out bool isSoleProvider) + { var isEnabled = false; isSoleProvider = true; foreach (var options in libraryOptions.TypeOptions) { diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index e5d85350..1523dcfe 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -128,8 +128,17 @@ public string BasePath /// <summary> /// "Virtual" File System Root Directory. /// </summary> - public string VirtualRoot => - _virtualRoot ??= Path.Join(Configuration.VFS_LiveInCache ? ApplicationPaths.CachePath : ApplicationPaths.ProgramDataPath, Name); + public string VirtualRoot + { + get + { + var virtualRoot = _virtualRoot ??= Path.Join(Configuration.VFS_LiveInCache ? ApplicationPaths.CachePath : ApplicationPaths.ProgramDataPath, Name); + if (!Directory.Exists(virtualRoot)) + Directory.CreateDirectory(virtualRoot); + + return virtualRoot; + } + } /// <summary> /// Gets or sets the event handler that is triggered when this configuration changes. diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index f3f94eaf..5e3f5101 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -99,7 +99,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file // because the VFS is pre-filtered, and we should **never** reach // this point except for the folders in the root of the media folder // that we're not even going to use. - if (mediaFolderConfig.IsVirtualFileSystemEnabled) + if (mediaFolderConfig.IsVirtualFileSystemEnabled || mediaFolderConfig.IsVirtualRoot) return true; var shouldIgnore = mediaFolderConfig.LibraryFilteringMode switch { diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index 4d1ccdfb..c65b1fef 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -108,7 +108,7 @@ public void StartWatching() continue; var libraryOptions = LibraryManager.GetLibraryOptions(mediaFolder); - if (libraryOptions != null && libraryOptions.EnableRealtimeMonitor && mediaConfig.IsVirtualFileSystemEnabled) + if (libraryOptions != null && !mediaConfig.IsVirtualRoot && libraryOptions.EnableRealtimeMonitor && mediaConfig.IsVirtualFileSystemEnabled) StartWatchingMediaFolder(mediaFolder, mediaConfig); } } @@ -134,7 +134,7 @@ private void OnMediaFolderConfigurationAddedOrUpdated(object? sender, MediaConfi return; var libraryOptions = LibraryManager.GetLibraryOptions(eventArgs.MediaFolder); - if (libraryOptions != null && libraryOptions.EnableRealtimeMonitor && eventArgs.Configuration.IsVirtualFileSystemEnabled) + if (libraryOptions != null && !eventArgs.Configuration.IsVirtualRoot && libraryOptions.EnableRealtimeMonitor && eventArgs.Configuration.IsVirtualFileSystemEnabled) StartWatchingMediaFolder(eventArgs.MediaFolder, eventArgs.Configuration); else StopWatchingPath(eventArgs.MediaFolder.Path); diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 06d894b9..1700236b 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -80,7 +80,7 @@ NamingOptions namingOptions return null; trackerId = Plugin.Instance.Tracker.Add($"Resolve path \"{fileInfo.FullName}\"."); - var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, fileInfo.FullName).ConfigureAwait(false); + var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, collectionType, fileInfo.FullName).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) return null; @@ -123,7 +123,7 @@ NamingOptions namingOptions return null; trackerId = Plugin.Instance.Tracker.Add($"Resolve children of \"{parent.Path}\". (Children={fileInfoList.Count})"); - var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, parent.Path).ConfigureAwait(false); + var (vfsPath, shouldContinue) = await ResolveManager.GenerateStructureInVFS(mediaFolder, collectionType, parent.Path).ConfigureAwait(false); if (string.IsNullOrEmpty(vfsPath) || !shouldContinue) return null; diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index a00b4815..a7416cdd 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -130,20 +130,24 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) /// <param name="mediaFolder">The media folder to generate a structure for.</param> /// <param name="path">The file or folder within the media folder to generate a structure for.</param> /// <returns>The VFS path, if it succeeded.</returns> - public async Task<(string?, bool)> GenerateStructureInVFS(Folder mediaFolder, string path) + public async Task<(string?, bool)> GenerateStructureInVFS(Folder mediaFolder, CollectionType? collectionType, string path) { - var (vfsPath, mainMediaFolderPath, collectionType, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, config => config.IsVirtualFileSystemEnabled); + var (vfsPath, mainMediaFolderPath, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, collectionType, config => config.IsVirtualFileSystemEnabled); if (string.IsNullOrEmpty(vfsPath) || string.IsNullOrEmpty(mainMediaFolderPath) || mediaConfigs.Count is 0) return (null, false); if (!Plugin.Instance.CanCreateSymbolicLinks) throw new Exception("Windows users are required to enable Developer Mode then restart Jellyfin to be able to create symbolic links, a feature required to use the VFS."); + var shouldContinue = path.StartsWith(vfsPath + Path.DirectorySeparatorChar) || path == mainMediaFolderPath; + if (!shouldContinue) + return (vfsPath, false); + // Skip link generation if we've already generated for the library. if (DataCache.TryGetValue<bool>($"should-skip-vfs-path:{vfsPath}", out var shouldReturnPath)) return ( shouldReturnPath ? vfsPath : null, - path.StartsWith(vfsPath + Path.DirectorySeparatorChar) || path == mainMediaFolderPath + true ); // Check full path and all parent directories if they have been indexed. @@ -158,7 +162,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } // Only do this once. - var key = mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) + var key = !path.StartsWith(vfsPath) && mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) ? $"should-skip-vfs-path:{vfsPath}" : $"should-skip-vfs-path:{path}"; shouldReturnPath = await DataCache.GetOrCreateAsync<bool>(key, async () => { @@ -239,7 +243,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } } // Iterate files in the "real" media folder. - else if (mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath))) { + else { var allPaths = GetPathsForMediaFolder(mediaConfigs); pathToClean = vfsPath; allFiles = GetFilesForImportFolder(mediaConfigs, allPaths); @@ -262,7 +266,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) return ( shouldReturnPath ? vfsPath : null, - path.StartsWith(vfsPath + Path.DirectorySeparatorChar) || path == mainMediaFolderPath + true ); } From 453dc77197bf905c90effcecfebbbc4249998328 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Fri, 20 Sep 2024 05:12:13 +0200 Subject: [PATCH 063/144] misc: fix up error message --- Shokofin/Resolvers/ShokoResolver.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 1700236b..27906749 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -209,7 +209,7 @@ NamingOptions namingOptions } } catch (Exception ex) { - Logger.LogTrace(ex, "Failed to remove "); + Logger.LogTrace(ex, "Failed to remove {Path}", pathToRemove); } } var deltaTime = DateTime.Now - start; From 68886b83ca9711e92de1613a75ed918a9c0f1105 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 22 Sep 2024 01:28:37 +0200 Subject: [PATCH 064/144] misc: also mark season ordering as expert --- Shokofin/Configuration/configPage.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e5639c79..b37f045a 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1155,7 +1155,7 @@ <h3>Library Settings</h3> </details> </div> </div> - <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription" hidden> + <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription expert-only" hidden> <label class="selectLabel" for="SeasonOrdering">Season ordering:</label> <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> <option value="Default" selected>Let Shoko decide</option> From 70bfe17ee0b5ba4bfc2d3b4057752fac62513e9e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 22 Sep 2024 03:23:31 +0200 Subject: [PATCH 065/144] fix: use older image types until next shoko server stable release --- Shokofin/API/Models/Image.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/API/Models/Image.cs b/Shokofin/API/Models/Image.cs index e01a84f8..03145a0f 100644 --- a/Shokofin/API/Models/Image.cs +++ b/Shokofin/API/Models/Image.cs @@ -124,22 +124,22 @@ public enum ImageType /// <summary> /// /// </summary> - Thumbnail = 3, + Thumb = 3, /// <summary> /// /// </summary> - Thumb = Thumbnail, + Thumbnail = Thumb, /// <summary> /// /// </summary> - Backdrop = 4, + Fanart = 4, /// <summary> /// /// </summary> - Fanart = Backdrop, + Backdrop = Fanart, /// <summary> /// From 5ea5cb192c2ca68bc224d0dd4e66f63493f0ef23 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 23 Sep 2024 01:21:33 +0200 Subject: [PATCH 066/144] misc: add path and/or other details when throwing - Added the path and/or other details when throwing in a provider, to more easily identify _which_ series/episode is throwing if the stack trace and/or error message is generic/useless. --- Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Providers/EpisodeProvider.cs | 10 +++++++++- Shokofin/Providers/MovieProvider.cs | 2 +- Shokofin/Providers/SeasonProvider.cs | 2 +- Shokofin/Providers/SeriesProvider.cs | 2 +- Shokofin/Providers/TrailerProvider.cs | 2 +- Shokofin/Providers/VideoProvider.cs | 2 +- 7 files changed, 15 insertions(+), 7 deletions(-) diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 41e1fd7c..381443af 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -50,7 +50,7 @@ public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, Cancellat return new(); } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, ex.Message); return new MetadataResult<BoxSet>(); } } diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 10a7caa0..67779204 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -86,7 +86,15 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell return result; } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + if (info.IsMissingEpisode || string.IsNullOrEmpty(info.Path)) { + if (!info.TryGetProviderId(ShokoEpisodeId.Name, out var episodeId)) + episodeId = null; + Logger.LogError(ex, "Threw unexpectedly while refreshing a missing episode; {Message} (Episode={EpisodeId})", ex.Message, episodeId); + } + else { + Logger.LogError(ex, "Threw unexpectedly while refreshing {Path}: {Message}", info.Path, info.IsMissingEpisode); + } + return new MetadataResult<Episode>(); } finally { diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 6c598e5e..03a5213e 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -83,7 +83,7 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio return result; } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, ex.Message); return new MetadataResult<Movie>(); } finally { diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 4f145a38..a16e55f8 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -91,7 +91,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat return result; } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Logger.LogError(ex, "Threw unexpectedly while refreshing season {SeasonNumber}; {Message} (Path={Path},Series={SeriesId})", info.IndexNumber, ex.Message, info.Path, seriesId); return new MetadataResult<Season>(); } finally { diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index de045382..ef468ae5 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -95,7 +95,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat return result; } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, ex.Message); return new MetadataResult<Series>(); } finally { diff --git a/Shokofin/Providers/TrailerProvider.cs b/Shokofin/Providers/TrailerProvider.cs index 22d6eefd..14de6862 100644 --- a/Shokofin/Providers/TrailerProvider.cs +++ b/Shokofin/Providers/TrailerProvider.cs @@ -68,7 +68,7 @@ public async Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, Cancell return result; } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Logger.LogError(ex, "Threw unexpectedly while refreshing path {Path}; {Message}", info.Path, ex.Message); return new MetadataResult<Trailer>(); } finally { diff --git a/Shokofin/Providers/VideoProvider.cs b/Shokofin/Providers/VideoProvider.cs index cafd8897..3581d970 100644 --- a/Shokofin/Providers/VideoProvider.cs +++ b/Shokofin/Providers/VideoProvider.cs @@ -68,7 +68,7 @@ public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, Cancel return result; } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Logger.LogError(ex, "Threw unexpectedly while refreshing {Path}; {Message}", info.Path, ex.Message); return new MetadataResult<Video>(); } finally { From 109b9673beb2f811d86d233310e0f459fef390cd Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Mon, 23 Sep 2024 14:34:09 +0200 Subject: [PATCH 067/144] fix: update version check to support physical vfs roots --- Shokofin/Tasks/VersionCheckTask.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Shokofin/Tasks/VersionCheckTask.cs b/Shokofin/Tasks/VersionCheckTask.cs index b1b6eebc..d9f7a45f 100644 --- a/Shokofin/Tasks/VersionCheckTask.cs +++ b/Shokofin/Tasks/VersionCheckTask.cs @@ -72,13 +72,16 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can mediaFolders .Select(m => m.ImportFolderId) .Distinct() - .Except([0]) + .Except([0, -1]) .Select(id => _apiClient.GetImportFolder(id)) .ToList() ) .ContinueWith(task => task.Result.OfType<ImportFolder>().ToDictionary(i => i.Id, i => i.Name)) .ConfigureAwait(false); foreach (var mediaFolderConfig in mediaFolders) { + if (mediaFolderConfig.IsVirtualRoot) + continue; + if (!importFolderNameMap.TryGetValue(mediaFolderConfig.ImportFolderId, out var importFolderName)) importFolderName = null; @@ -90,7 +93,7 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can updated = true; } - if (!string.Equals(mediaFolderConfig.ImportFolderName, importFolderName)) { + if (!string.IsNullOrEmpty(importFolderName) && !string.Equals(mediaFolderConfig.ImportFolderName, importFolderName)) { _logger.LogDebug("Found new name for import folder; {name} (ImportFolder={ImportFolderId})", importFolderName, mediaFolderConfig.ImportFolderId); mediaFolderConfig.ImportFolderName = importFolderName; updated = true; @@ -100,4 +103,4 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can Plugin.Instance.UpdateConfiguration(); } } -} \ No newline at end of file +} From 0d765de84e1af913da5e90297e6fa91a2383fc2e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 24 Sep 2024 16:01:31 +0200 Subject: [PATCH 068/144] fix: fix up media folder and user selectors - Fixed the media selectors I broke. - Sync media folder settings between the same library _and_ any other libraries that contain the same media folder. - Fixed loading animation for initial load. --- Shokofin/Configuration/configController.js | 52 +++++++++++++++------- 1 file changed, 36 insertions(+), 16 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 2f45fbdc..59ba9b78 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -116,13 +116,16 @@ async function loadUserConfig(form, userId, config) { return; } - Dashboard.showLoadingMsg(); - // Get the configuration to use. - if (!config) config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId) - const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; + let shouldHide = false; + if (!config) { + Dashboard.showLoadingMsg(); + config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + shouldHide = true; + } // Configure the elements within the user container + const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; form.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; form.querySelector("#SyncUserDataOnImport").checked = userConfig.SyncUserDataOnImport || false; form.querySelector("#SyncUserDataAfterPlayback").checked = userConfig.SyncUserDataAfterPlayback || false; @@ -149,26 +152,35 @@ async function loadUserConfig(form, userId, config) { // Show the user settings now if it was previously hidden. form.querySelector("#UserSettingsContainer").removeAttribute("hidden"); - Dashboard.hideLoadingMsg(); + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } } -async function loadMediaFolderConfig(form, mediaFolderId, config) { - if (!mediaFolderId) { +async function loadMediaFolderConfig(form, selectedValue, config) { + const [mediaFolderId, libraryId] = selectedValue.split(","); + if (!mediaFolderId || !libraryId) { form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); Dashboard.hideLoadingMsg(); return; } - Dashboard.showLoadingMsg(); - // Get the configuration to use. - if (!config) config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId) - const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId); + let shouldHide = false; + if (!config) { + Dashboard.showLoadingMsg(); + config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + shouldHide = true; + } + + const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId && libraryId === c.LibraryId); if (!mediaFolderConfig) { form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - Dashboard.hideLoadingMsg(); + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } return; } @@ -182,7 +194,9 @@ async function loadMediaFolderConfig(form, mediaFolderId, config) { form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); - Dashboard.hideLoadingMsg(); + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } } async function loadSignalrMediaFolderConfig(form, mediaFolderId, config) { @@ -600,9 +614,15 @@ async function syncMediaFolderSettings(form) { config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; if (mediaFolderConfig) { - for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { - c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; + // We need to update the config for all libraries that use this media folder, because + // otherwise we will experience edge cases where the media folder is used in multiple + // libraries potentially with and without the VFS enabled. + const libraryIDs = config.MediaFolders.filter(m => m.MediaFolderId === mediaFolderId).map(m => m.LibraryId); + for (const libraryId of libraryIDs) { + for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { + c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; + c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; + } } } else { From 9b3fce1a8179d09d9a8207915e043ce8b6b42c54 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 00:14:17 +0200 Subject: [PATCH 069/144] fix: fix guard for iterating import folder files - Fixed the guard for iterating import folder files to once again check if the current path is a media folder, but this time also check if it's the physical VFS root attached to the library. With this check in place then it will prevent other locations from triggering a full refresh of the VFS, e.g. old VFS roots and/or other media folders attached to the library that are not part of the VFS. --- Shokofin/Resolvers/VirtualFileSystemService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index a7416cdd..ebaede81 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -243,7 +243,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } } // Iterate files in the "real" media folder. - else { + else if (mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) || path == vfsPath) { var allPaths = GetPathsForMediaFolder(mediaConfigs); pathToClean = vfsPath; allFiles = GetFilesForImportFolder(mediaConfigs, allPaths); From aadf950387802dd7dc9ca5b0872fb90a8077fd4e Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 00:22:49 +0200 Subject: [PATCH 070/144] refactor: overhaul the merge versions manager - Refactored the merge versions manager to re-use logic and implement generics in the merge/split process. --- Shokofin/MergeVersions/MergeVersionManager.cs | 237 +++++++----------- Shokofin/Tasks/MergeEpisodesTask.cs | 2 +- Shokofin/Tasks/MergeMoviesTask.cs | 2 +- Shokofin/Tasks/PostScanTask.cs | 2 +- 4 files changed, 89 insertions(+), 154 deletions(-) diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index dbd7b896..d51854cb 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -39,14 +39,13 @@ public class MergeVersionsManager /// </summary> /// <param name="libraryManager">Library manager.</param> /// <param name="lookup">Shoko ID Lookup.</param> - /// <param name="logger">Logger.</param> public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup) { LibraryManager = libraryManager; Lookup = lookup; } - #region Shared + #region Top Level /// <summary> /// Group and merge all videos with a Shoko Episode ID set. @@ -55,7 +54,7 @@ public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup) /// <param name="cancellationToken">Cancellation token.</param> /// <returns>An async task that will silently complete when the merging is /// complete.</returns> - public async Task MergeAll(IProgress<double> progress, CancellationToken cancellationToken) + public async Task SplitAndMergeAll(IProgress<double>? progress, CancellationToken? cancellationToken = null) { // Shared progress; double episodeProgressValue = 0d, movieProgressValue = 0d; @@ -65,17 +64,19 @@ public async Task MergeAll(IProgress<double> progress, CancellationToken cancell movieProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); - var movieTask = MergeAllMovies(movieProgress, cancellationToken); + var movieTask = SplitAndMergeVideos(GetMoviesFromLibrary(), movieProgress, cancellationToken); // Setup the episode task. var episodeProgress = new Progress<double>(value => { episodeProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); - var episodeTask = MergeAllEpisodes(episodeProgress, cancellationToken); + var episodeTask = SplitAndMergeVideos(GetEpisodesFromLibrary(), episodeProgress, cancellationToken); // Run them in parallel. await Task.WhenAll(movieTask, episodeTask); + + progress?.Report(100d); } /// <summary> @@ -95,7 +96,7 @@ public async Task SplitAll(IProgress<double> progress, CancellationToken cancell movieProgressValue = value / 2d; progress?.Report(movieProgressValue + episodeProgressValue); }); - var movieTask = SplitAllMovies(movieProgress, cancellationToken); + var movieTask = SplitVideos(GetMoviesFromLibrary(), movieProgress, cancellationToken); // Setup the episode task. var episodeProgress = new Progress<double>(value => { @@ -103,218 +104,151 @@ public async Task SplitAll(IProgress<double> progress, CancellationToken cancell progress?.Report(movieProgressValue + episodeProgressValue); progress?.Report(50d + (value / 2d)); }); - var episodeTask = SplitAllEpisodes(episodeProgress, cancellationToken); + var episodeTask = SplitVideos(GetMoviesFromLibrary(), episodeProgress, cancellationToken); // Run them in parallel. await Task.WhenAll(movieTask, episodeTask); } - #endregion Shared - #region Movies + #endregion - /// <summary> - /// Get all movies with a Shoko Episode ID set across all libraries. - /// </summary> - /// <returns>A list of all movies with a Shoko Episode ID set.</returns> - private List<Movie> GetMoviesFromLibrary() - { - return LibraryManager.GetItemList(new() { - IncludeItemTypes = [BaseItemKind.Movie], - IsVirtualItem = false, - Recursive = true, - HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, - }) - .Cast<Movie>() - .Where(Lookup.IsEnabledForItem) - .ToList(); - } + #region Episode Level - /// <summary> - /// Merge movie entries together. - /// </summary> - /// <param name="movies">Movies to merge.</param> - /// <returns>An async task that will silently complete when the merging is - /// complete.</returns> - public static async Task MergeMovies(IEnumerable<Movie> movies) - => await MergeVideos(movies.Cast<Video>().OrderBy(e => e.Id).ToList()); + public async Task SplitAndMergeAllEpisodes(IProgress<double>? progress, CancellationToken? cancellationToken) + => await SplitAndMergeVideos(GetEpisodesFromLibrary(), progress, cancellationToken); - /// <summary> - /// Merge all movie entries with a Shoko Episode ID set. - /// </summary> - /// <param name="progress">Progress indicator.</param> - /// <param name="cancellationToken">Cancellation token.</param> - /// <returns>An async task that will silently complete when the merging is - /// complete.</returns> - public async Task MergeAllMovies(IProgress<double> progress, CancellationToken cancellationToken) - { - // Split up any existing merged movies. - var movies = GetMoviesFromLibrary(); - double currentCount = 0d; - double totalCount = movies.Count; - foreach (var movie in movies) { - // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalCount) * 50d; - progress?.Report(percent); + public async Task SplitAllEpisodes(IProgress<double>? progress, CancellationToken? cancellationToken) + => await SplitVideos(GetEpisodesFromLibrary(), progress, cancellationToken); - // Remove all alternate sources linked to the movie. - await RemoveAlternateSources(movie); - } + #endregion - // Merge all movies with more than one version (again). - var duplicationGroups = movies - .GroupBy(movie => (movie.GetTopParent()?.Path, movie.GetProviderId(ShokoEpisodeId.Name))) - .Where(movie => movie.Count() > 1) - .ToList(); - currentCount = 0d; - totalCount = duplicationGroups.Count; - foreach (var movieGroup in duplicationGroups) { - // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = 50d + ((currentCount++ / totalCount) * 50d); - progress?.Report(percent); + #region Movie Level - // Link the movies together as alternate sources. - await MergeMovies(movieGroup); - } + public async Task SplitAndMergeAllMovies(IProgress<double>? progress, CancellationToken? cancellationToken) + => await SplitAndMergeVideos(GetMoviesFromLibrary(), progress, cancellationToken); - progress?.Report(100); - } + public async Task SplitAllMovies(IProgress<double>? progress, CancellationToken? cancellationToken) + => await SplitVideos(GetMoviesFromLibrary(), progress, cancellationToken); - /// <summary> - /// Split up all existing merged movies with a Shoko Episode ID set. - /// </summary> - /// <param name="progress">Progress indicator.</param> - /// <param name="cancellationToken">Cancellation token.</param> - /// <returns>An async task that will silently complete when the splitting is - /// complete.</returns> - public async Task SplitAllMovies(IProgress<double> progress, CancellationToken cancellationToken) - { - // Split up any existing merged movies. - var movies = GetMoviesFromLibrary(); - double currentCount = 0d; - double totalMovies = movies.Count; - foreach (var movie in movies) { - // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalMovies) * 100d; - progress?.Report(percent); + #endregion - // Remove all alternate sources linked to the movie. - await RemoveAlternateSources(movie); - } + #region Shared Methods - progress?.Report(100); - } - - #endregion Movies - #region Episodes + /// <summary> + /// Get all movies with a Shoko Episode ID set across all libraries. + /// </summary> + /// <param name="episodeId">Optional. The episode id if we want to filter to only movies with a given Shoko Episode ID.</param> + /// <returns>A list of all movies with the given <paramref name="episodeId"/> set.</returns> + public IReadOnlyList<Movie> GetMoviesFromLibrary(string episodeId = "") + => LibraryManager + .GetItemList(new() { + IncludeItemTypes = [BaseItemKind.Movie], + IsVirtualItem = false, + Recursive = true, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, episodeId } }, + }) + .OfType<Movie>() + .Where(Lookup.IsEnabledForItem) + .ToList(); /// <summary> /// Get all episodes with a Shoko Episode ID set across all libraries. /// </summary> + /// <param name="episodeId">Optional. The episode id if we want to filter to only episodes with a given Shoko Episode ID.</param> /// <returns>A list of all episodes with a Shoko Episode ID set.</returns> - private List<Episode> GetEpisodesFromLibrary() - { - return LibraryManager.GetItemList(new() { + public IReadOnlyList<Episode> GetEpisodesFromLibrary(string episodeId = "") + => LibraryManager + .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Episode], - HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, string.Empty } }, + HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, episodeId } }, IsVirtualItem = false, Recursive = true, }) .Cast<Episode>() .Where(Lookup.IsEnabledForItem) .ToList(); - } /// <summary> - /// Merge episode entries together. - /// </summary> - /// <param name="episodes">Episodes to merge.</param> - /// <returns>An async task that will silently complete when the merging is - /// complete.</returns> - public static async Task MergeEpisodes(IEnumerable<Episode> episodes) - => await MergeVideos(episodes.Cast<Video>().OrderBy(e => e.Id).ToList()); - - /// <summary> - /// Split up all existing merged versions of each episode and merge them - /// again afterwards. Only applied to episodes with a Shoko Episode ID set. + /// Merge all videos with a Shoko Episode ID set. /// </summary> /// <param name="progress">Progress indicator.</param> /// <param name="cancellationToken">Cancellation token.</param> - /// <returns>An async task that will silently complete when the splitting - /// followed by merging is complete.</returns> - public async Task MergeAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + /// <returns>An async task that will silently complete when the merging is + /// complete.</returns> + public async Task SplitAndMergeVideos<TVideo>( + IReadOnlyList<TVideo> videos, + IProgress<double>? progress = null, + CancellationToken? cancellationToken = null + ) where TVideo : Video { - // Split up any existing merged episodes. - var episodes = GetEpisodesFromLibrary(); + // Split up any existing merged videos. double currentCount = 0d; - double totalCount = episodes.Count; - foreach (var e in episodes) { + double totalCount = videos.Count; + foreach (var video in videos) { // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalCount) * 100d; + cancellationToken?.ThrowIfCancellationRequested(); + var percent = currentCount++ / totalCount * 50d; progress?.Report(percent); - // Remove all alternate sources linked to the episode. - await RemoveAlternateSources(e); + // Remove all alternate sources linked to the video. + await RemoveAlternateSources(video); } - // Merge episodes with more than one version (again), and with the same - // number of additional episodes. - var duplicationGroups = episodes - .GroupBy(e => (e.GetTopParent()?.Path, $"{e.GetProviderId(ShokoEpisodeId.Name)}-{(e.IndexNumberEnd ?? e.IndexNumber ?? 1) - (e.IndexNumber ?? 1)}")) - .Where(e => e.Count() > 1) + // Merge all videos with more than one version (again). + var duplicationGroups = videos + .GroupBy(video => (video.GetTopParent()?.Path, video.GetProviderId(ShokoEpisodeId.Name))) + .Where(groupBy => groupBy.Count() > 1) .ToList(); currentCount = 0d; totalCount = duplicationGroups.Count; - foreach (var episodeGroup in duplicationGroups) { + foreach (var videoGroup in duplicationGroups) { // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = currentCount++ / totalCount * 100d; + cancellationToken?.ThrowIfCancellationRequested(); + var percent = 50d + (currentCount++ / totalCount * 50d); progress?.Report(percent); - // Link the episodes together as alternate sources. - await MergeEpisodes(episodeGroup); + // Link the videos together as alternate sources. + await MergeVideos(videoGroup); } + + progress?.Report(100); } /// <summary> - /// Split up all existing merged episodes with a Shoko Episode ID set. + /// Split up all existing merged videos with a Shoko Episode ID set. /// </summary> /// <param name="progress">Progress indicator.</param> /// <param name="cancellationToken">Cancellation token.</param> /// <returns>An async task that will silently complete when the splitting is /// complete.</returns> - public async Task SplitAllEpisodes(IProgress<double> progress, CancellationToken cancellationToken) + public async Task SplitVideos<TVideo>(IReadOnlyList<TVideo> videos, IProgress<double>? progress, CancellationToken? cancellationToken) where TVideo : Video { - // Split up any existing merged episodes. - var episodes = GetEpisodesFromLibrary(); + // Split up any existing merged videos. double currentCount = 0d; - double totalEpisodes = episodes.Count; - foreach (var e in episodes) { + double totalMovies = videos.Count; + foreach (var video in videos) { // Handle cancellation and update progress. - cancellationToken.ThrowIfCancellationRequested(); - var percent = (currentCount++ / totalEpisodes) * 100d; + cancellationToken?.ThrowIfCancellationRequested(); + var percent = currentCount++ / totalMovies * 100d; progress?.Report(percent); - // Remove all alternate sources linked to the episode. - await RemoveAlternateSources(e); + // Remove all alternate sources linked to the video. + await RemoveAlternateSources(video); } progress?.Report(100); } - #endregion Episodes - /// <summary> /// Merges multiple videos into a single UI element. /// </summary> /// /// Modified from; /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L192 - private static async Task MergeVideos(List<Video> videos) + private static async Task MergeVideos<TVideo>(IEnumerable<TVideo> input) where TVideo : Video { + if (input is not IList<TVideo> videos) + videos = input.ToList(); if (videos.Count < 2) return; @@ -357,8 +291,7 @@ await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationTok .ConfigureAwait(false); } - primaryVersion.LinkedAlternateVersions = alternateVersionsOfPrimary - .ToArray(); + primaryVersion.LinkedAlternateVersions = [.. alternateVersionsOfPrimary]; await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } @@ -371,7 +304,7 @@ await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, Cancel /// /// Modified from; /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L152 - private async Task RemoveAlternateSources(Video video) + private async Task RemoveAlternateSources<TVideo>(TVideo video) where TVideo : Video { // Find the primary video. if (video.LinkedAlternateVersions.Length == 0) { @@ -380,7 +313,7 @@ private async Task RemoveAlternateSources(Video video) return; // Make sure the primary video still exists before we proceed. - if (LibraryManager.GetItemById(video.PrimaryVersionId) is not Video primaryVideo) + if (LibraryManager.GetItemById(video.PrimaryVersionId) is not TVideo primaryVideo) return; video = primaryVideo; } @@ -400,4 +333,6 @@ await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, Cancellat await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } + + #endregion Shared Methods } diff --git a/Shokofin/Tasks/MergeEpisodesTask.cs b/Shokofin/Tasks/MergeEpisodesTask.cs index 13c1d027..a6476dbc 100644 --- a/Shokofin/Tasks/MergeEpisodesTask.cs +++ b/Shokofin/Tasks/MergeEpisodesTask.cs @@ -49,7 +49,7 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can return; using (Plugin.Instance.Tracker.Enter("Merge Episodes Task")) { - await _mergeVersionManager.MergeAllEpisodes(progress, cancellationToken); + await _mergeVersionManager.SplitAndMergeAllEpisodes(progress, cancellationToken); } } } diff --git a/Shokofin/Tasks/MergeMoviesTask.cs b/Shokofin/Tasks/MergeMoviesTask.cs index 74443b6f..0264cfd3 100644 --- a/Shokofin/Tasks/MergeMoviesTask.cs +++ b/Shokofin/Tasks/MergeMoviesTask.cs @@ -49,7 +49,7 @@ public async Task ExecuteAsync(IProgress<double> progress, CancellationToken can return; using (Plugin.Instance.Tracker.Enter("Merge Movies Task")) { - await VersionsManager.MergeAllMovies(progress, cancellationToken); + await VersionsManager.SplitAndMergeAllMovies(progress, cancellationToken); } } } diff --git a/Shokofin/Tasks/PostScanTask.cs b/Shokofin/Tasks/PostScanTask.cs index cc8db7d8..619c8c24 100644 --- a/Shokofin/Tasks/PostScanTask.cs +++ b/Shokofin/Tasks/PostScanTask.cs @@ -23,7 +23,7 @@ public async Task Run(IProgress<double> progress, CancellationToken token) var simpleProgress = new Progress<double>(value => progress.Report(baseProgress + (value / 2d))); // Merge versions. - await _mergeVersionsManager.MergeAll(simpleProgress, token); + await _mergeVersionsManager.SplitAndMergeAll(simpleProgress, token); // Reconstruct collections. baseProgress = 50; From 00465e5823921f112733e8c04fb5af63c1fc6661 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 00:36:57 +0200 Subject: [PATCH 071/144] fix: auto remove 'physical' VFS roots when needed --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 062369e6..71f0ef58 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -283,7 +283,6 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders ) .ToList(); var config = Plugin.Instance.Configuration; - var attachRoot = config.VFS_AttachRoot; foreach (var virtualFolder in filteredVirtualFolders) { if (!Guid.TryParse(virtualFolder.ItemId, out var libraryId) || LibraryManager.GetItemById(libraryId) is not Folder libraryFolder) throw new Exception($"Unable to find virtual folder \"{virtualFolder.Name}\""); @@ -306,11 +305,12 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders mediaFolderConfig = CreateConfigurationForPath(libraryId, secondFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); } - if (!attachRoot || !(mediaFolderConfig?.IsVirtualFileSystemEnabled ?? false)) + if (mediaFolderConfig is null) continue; var vfsPath = libraryFolder.GetVirtualRoot(); - if (!virtualFolder.Locations.Contains(vfsPath, Path.DirectorySeparatorChar is '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)) { + var shouldAttach = config.VFS_AttachRoot && mediaFolderConfig.IsVirtualFileSystemEnabled; + if (shouldAttach && !virtualFolder.Locations.Contains(vfsPath, Path.DirectorySeparatorChar is '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)) { if (!LibraryEdits.TryGetValue(libraryId, out var edits)) LibraryEdits[libraryId] = edits = (libraryFolder.Name, [], []); edits.add.Add(vfsPath); @@ -318,7 +318,7 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders var virtualRoot = Plugin.Instance.VirtualRoot; var toRemove = virtualFolder.Locations - .Except([vfsPath]) + .Except(shouldAttach ? [vfsPath] : []) .Where(location => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) .ToList(); if (toRemove.Count > 0) { From 2da817f49b4859567c80bf0c1bd951fd50bb4168 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 00:47:30 +0200 Subject: [PATCH 072/144] fix: skip generation of vfs if we need to edit the libraries --- .../MediaFolderConfigurationService.cs | 33 +++++++++---------- .../Resolvers/VirtualFileSystemService.cs | 6 +++- 2 files changed, 21 insertions(+), 18 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 71f0ef58..bbaf253a 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -60,6 +60,8 @@ public class MediaFolderConfigurationService private readonly Dictionary<Guid, string> MediaFolderChangeKeys = []; + private readonly Dictionary<Guid, (string libraryName, HashSet<string> add, HashSet<string> remove)> LibraryEdits = []; + private bool ShouldGenerateAllConfigurations = true; private readonly object LockObj = new(); @@ -69,8 +71,6 @@ public class MediaFolderConfigurationService public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationUpdated; public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationRemoved; - - public readonly Dictionary<Guid, (string libraryName, HashSet<string> add, HashSet<string> remove)> LibraryEdits = []; public MediaFolderConfigurationService( ILogger<MediaFolderConfigurationService> logger, @@ -118,15 +118,15 @@ private void OnLibraryScanValueChanged(object? sender, bool isRunning) if (isRunning) return; - Task.Run(EditLibraries); + Task.Run(() => EditLibraries(true)); } private void OnUsageTrackerStalled(object? sender, EventArgs eventArgs) { - Task.Run(EditLibraries); + Task.Run(() => EditLibraries(false)); } - private void EditLibraries() + private void EditLibraries(bool shouldScheduleLibraryScan) { lock (LockObj) { if (LibraryEdits.Count is 0) @@ -151,7 +151,8 @@ private void EditLibraries() foreach (var vfsPath in remove) LibraryManager.RemoveMediaPath(libraryName, new(vfsPath)); } - LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); + if (shouldScheduleLibraryScan) + LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); } } @@ -224,30 +225,28 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } } - public (string vfsPath, string mainMediaFolderPath, IReadOnlyList<MediaFolderConfiguration> mediaList) GetAvailableMediaFoldersForLibrary(Folder mediaFolder, CollectionType? collectionType, Func<MediaFolderConfiguration, bool>? filter = null) + public (string vfsPath, string mainMediaFolderPath, IReadOnlyList<MediaFolderConfiguration> mediaList, bool skipGeneration) GetMediaFoldersForLibraryInVFS(Folder mediaFolder, CollectionType? collectionType, Func<MediaFolderConfiguration, bool>? filter = null) { - var attachRoot = Plugin.Instance.Configuration.VFS_AttachRoot; var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder, collectionType); lock (LockObj) { + var skipGeneration = LibraryEdits.Count is > 0 && LibraryManager.IsScanRunning; if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) - return (string.Empty, string.Empty, []); + return (string.Empty, string.Empty, [], skipGeneration); + var virtualFolder = LibraryManager.GetVirtualFolders() .FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == mediaFolderConfig.LibraryId); if (virtualFolder is null || virtualFolder.Locations.Length is 0) - return (string.Empty, string.Empty, []); + return (string.Empty, string.Empty, [], skipGeneration); var vfsPath = libraryFolder.GetVirtualRoot(); var mediaFolders = Plugin.Instance.Configuration.MediaFolders .Where(config => config.IsMapped && !config.IsVirtualRoot && config.LibraryId == mediaFolderConfig.LibraryId && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) .ToList(); - if (attachRoot && mediaFolderConfig.IsVirtualFileSystemEnabled) - return (vfsPath, vfsPath, mediaFolders); + if (Plugin.Instance.Configuration.VFS_AttachRoot && mediaFolderConfig.IsVirtualFileSystemEnabled) + return (vfsPath, vfsPath, mediaFolders, skipGeneration); - return ( - libraryFolder.GetVirtualRoot(), - virtualFolder.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty, - mediaFolders - ); + var mainMediaFolderPath = virtualFolder.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty; + return (vfsPath, mainMediaFolderPath, mediaFolders, skipGeneration); } } diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index ebaede81..e017112d 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -132,7 +132,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) /// <returns>The VFS path, if it succeeded.</returns> public async Task<(string?, bool)> GenerateStructureInVFS(Folder mediaFolder, CollectionType? collectionType, string path) { - var (vfsPath, mainMediaFolderPath, mediaConfigs) = ConfigurationService.GetAvailableMediaFoldersForLibrary(mediaFolder, collectionType, config => config.IsVirtualFileSystemEnabled); + var (vfsPath, mainMediaFolderPath, mediaConfigs, skipGeneration) = ConfigurationService.GetMediaFoldersForLibraryInVFS(mediaFolder, collectionType, config => config.IsVirtualFileSystemEnabled); if (string.IsNullOrEmpty(vfsPath) || string.IsNullOrEmpty(mainMediaFolderPath) || mediaConfigs.Count is 0) return (null, false); @@ -252,6 +252,10 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) if (allFiles is null) return false; + // Skip generation if we're going to (re-)schedule a library scan. + if (skipGeneration) + return true; + // Generate and cleanup the structure in the VFS. var result = await GenerateStructure(collectionType, vfsPath, allFiles); if (!string.IsNullOrEmpty(pathToClean)) From 7f481952d6db6ce19067b72b7097ed246e2e3639 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 00:58:24 +0200 Subject: [PATCH 073/144] revert: "fix: fix up media folder and user selectors" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This commit partially reverts commit 0d765de84e1af913da5e90297e6fa91a2383fc2e. The updated sync media folder logic was flawed, and fixing it will produce spaghetti so instead I'll revert the changes and trust the users to not make mistakes. 📿 --- Shokofin/Configuration/configController.js | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 59ba9b78..959b9a2e 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -614,15 +614,9 @@ async function syncMediaFolderSettings(form) { config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; if (mediaFolderConfig) { - // We need to update the config for all libraries that use this media folder, because - // otherwise we will experience edge cases where the media folder is used in multiple - // libraries potentially with and without the VFS enabled. - const libraryIDs = config.MediaFolders.filter(m => m.MediaFolderId === mediaFolderId).map(m => m.LibraryId); - for (const libraryId of libraryIDs) { - for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { - c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; - } + for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { + c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; + c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; } } else { From 4744a56694c4760a437282d733a0c1f13226de24 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 01:22:30 +0200 Subject: [PATCH 074/144] misc: rename method to better clarify what it's for --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 3 +-- Shokofin/Events/EventDispatchService.cs | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index bbaf253a..fcc0ca7a 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -199,7 +199,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) #region Media Folder Mapping - public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibraries(Func<MediaFolderConfiguration, bool>? filter = null) + public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibrariesForEvents(Func<MediaFolderConfiguration, bool>? filter = null) { lock (LockObj) { var virtualFolders = LibraryManager.GetVirtualFolders(); @@ -258,7 +258,6 @@ public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder me throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); var config = Plugin.Instance.Configuration; - var attachRoot = config.VFS_AttachRoot; var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id && c.LibraryId == libraryId) ?? CreateConfigurationForPath(libraryId, mediaFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index 36f87a2c..7351711a 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -211,7 +211,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var locationsToNotify = new List<string>(); var mediaFoldersToNotify = new Dictionary<string, (string pathToReport, Folder mediaFolder)>(); var seriesIds = await GetSeriesIdsForFile(fileId, changes.Select(t => t.Event).LastOrDefault(e => e.HasCrossReferences)); - var libraries = ConfigurationService.GetAvailableMediaFoldersForLibraries(c => c.IsFileEventsEnabled); + var libraries = ConfigurationService.GetAvailableMediaFoldersForLibrariesForEvents(c => c.IsFileEventsEnabled); var (reason, importFolderId, relativePath, lastEvent) = changes.Last(); if (reason is not UpdateReason.Removed) { Logger.LogTrace("Processing file changed. (File={FileId})", fileId); From 922c5abd93e87ecad0233a5cedd46b46814336e8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 01:31:06 +0200 Subject: [PATCH 075/144] fix: stop skipping cache for import folder - Stop skipping the cache when getting the files for an import folder. Now that the usage tracker exists it's not needed, because the cache will be cleared when nothing is using it. --- Shokofin/API/ShokoAPIClient.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 168e4fe9..cfa84dd7 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -288,10 +288,10 @@ public async Task<IReadOnlyList<File>> GetFilesForSeries(string seriesId) public async Task<ListResult<File>> GetFilesForImportFolder(int importFolderId, string subPath, int page = 1) { if (UseOlderImportFolderFileEndpoints) { - return await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?page={page}&pageSize=100&includeXRefs=true", skipCache: true).ConfigureAwait(false); + return await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?page={page}&pageSize=100&includeXRefs=true").ConfigureAwait(false); } - return await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?page={page}&folderPath={Uri.EscapeDataString(subPath)}&pageSize=1000&include=XRefs", skipCache: true).ConfigureAwait(false); + return await Get<ListResult<File>>($"/api/v3/ImportFolder/{importFolderId}/File?page={page}&folderPath={Uri.EscapeDataString(subPath)}&pageSize=1000&include=XRefs").ConfigureAwait(false); } public async Task<File.UserStats?> GetFileUserStats(string fileId, string? apiKey = null) From 4bbe366bdb27a4f6c7e40c80ac620a5fb82da9da Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 02:27:06 +0200 Subject: [PATCH 076/144] feat: add custom VFS location - Add an ** EXPERIMENTAL** option to specify a custom VFS root location. So now you can select between placing it in a) the jellyfin data directory (_the default_), b) the jellyfin cache directory, or c) somewhere you want it to live. Just note that by changing the setting you will need to either refresh and see your library turn to dust and rebuild itself, or take matters into your own hands and recreate it yourself. --- .../MediaFolderConfigurationService.cs | 8 +++-- Shokofin/Configuration/PluginConfiguration.cs | 18 ++++++++---- Shokofin/Configuration/VirtualRootLocation.cs | 12 ++++++++ Shokofin/Configuration/configController.js | 29 ++++++++++++++++--- Shokofin/Configuration/configPage.html | 20 ++++++++----- Shokofin/Plugin.cs | 16 +++++++++- Shokofin/Tasks/CleanupVirtualRootTask.cs | 23 +++++++++++++-- 7 files changed, 104 insertions(+), 22 deletions(-) create mode 100644 Shokofin/Configuration/VirtualRootLocation.cs diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index fcc0ca7a..6b1f3c6b 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -314,10 +314,14 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders edits.add.Add(vfsPath); } - var virtualRoot = Plugin.Instance.VirtualRoot; + var virtualRoots = new string[] { + Plugin.Instance.VirtualRoot_Default, + Plugin.Instance.VirtualRoot_Cache, + Plugin.Instance.VirtualRoot_Custom ?? string.Empty, + }.Where(s => !string.IsNullOrEmpty(s)).ToArray(); var toRemove = virtualFolder.Locations .Except(shouldAttach ? [vfsPath] : []) - .Where(location => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal)) + .Where(location => virtualRoots.Any(virtualRoot => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) .ToList(); if (toRemove.Count > 0) { if (!LibraryEdits.TryGetValue(libraryId, out var edits)) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 0444429a..ced5b6f4 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -411,16 +411,23 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool VFS_AddResolution { get; set; } + /// <summary> + /// Attach a physical VFS root as a media folder instead of attaching the + /// VFS children to one of the "normal" media folders. + /// </summary> + public bool VFS_AttachRoot { get; set; } + /// <summary> /// Places the VFS in the cache directory instead of the config directory. /// </summary> - public bool VFS_LiveInCache { get; set; } + [JsonConverter(typeof(JsonStringEnumConverter))] + public VirtualRootLocation VFS_Location { get; set; } /// <summary> - /// Attach a physical VFS root as a media folder instead of attaching the - /// VFS children to one of the "normal" media folders. + /// The custom location for the VFS root, if specified. Should be an + /// absolute path or a path relative to the config directory. /// </summary> - public bool VFS_AttachRoot { get; set; } + public string? VFS_CustomLocation { get; set; } /// <summary> /// Enable/disable the filtering for new media-folders/libraries. @@ -587,8 +594,9 @@ public PluginConfiguration() VFS_Threads = 4; VFS_AddReleaseGroup = false; VFS_AddResolution = false; - VFS_LiveInCache = false; VFS_AttachRoot = false; + VFS_Location = VirtualRootLocation.Default; + VFS_CustomLocation = null; AutoMergeVersions = true; UseGroupsForShows = false; SeparateMovies = false; diff --git a/Shokofin/Configuration/VirtualRootLocation.cs b/Shokofin/Configuration/VirtualRootLocation.cs new file mode 100644 index 00000000..6a20633b --- /dev/null +++ b/Shokofin/Configuration/VirtualRootLocation.cs @@ -0,0 +1,12 @@ + +namespace Shokofin.Configuration; + +/// <summary> +/// The virtual root location. +/// </summary> +public enum VirtualRootLocation +{ + Default = 0, + Cache = 1, + Custom = 2, +} \ No newline at end of file diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 959b9a2e..6746e84a 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -344,8 +344,9 @@ async function defaultSubmit(form) { form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; - config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; + config.VFS_Location = form.querySelector("#VFS_Location").value; + config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; if (mediaFolderConfig) { const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { @@ -550,8 +551,9 @@ async function syncSettings(form) { config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Experimental settings - config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; + config.VFS_Location = form.querySelector("#VFS_Location").value; + config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); @@ -611,8 +613,9 @@ async function syncMediaFolderSettings(form) { form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; - config.VFS_LiveInCache = form.querySelector("#VFS_LiveInCache").checked; config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; + config.VFS_Location = form.querySelector("#VFS_Location").value; + config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; if (mediaFolderConfig) { for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; @@ -849,6 +852,16 @@ export default function (page) { form.querySelector("#SyncUserDataInitialSkipEventCount").disabled = disabled; }); + form.querySelector("#VFS_Location").addEventListener("change", function () { + form.querySelector("#VFS_CustomLocation").disabled = this.value !== "Custom"; + if (this.value === "Custom") { + form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); + } + else { + form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); + } + }); + form.querySelector("#UseGroupsForShows").addEventListener("change", function () { form.querySelector("#SeasonOrdering").disabled = !this.checked; if (this.checked) { @@ -1061,8 +1074,16 @@ export default function (page) { form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; - form.querySelector("#VFS_LiveInCache").checked = config.VFS_LiveInCache; form.querySelector("#VFS_AttachRoot").checked = config.VFS_AttachRoot; + form.querySelector("#VFS_Location").value = config.VFS_Location; + form.querySelector("#VFS_CustomLocation").value = config.VFS_CustomLocation || ""; + form.querySelector("#VFS_CustomLocation").disabled = config.VFS_Location !== "Custom"; + if (config.VFS_Location === "Custom") { + form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); + } + else { + form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); + } form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; mediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index b37f045a..5d232c9e 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1608,13 +1608,6 @@ <h3>User Settings</h3> <h3>Experimental Settings</h3> </legend> <div class="fieldDescription verticalSection-extrabottompadding">Any features/settings in this section is still considered to be in an experimental state. <strong>You can enable them, but at the risk if them messing up your library.</strong></div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="VFS_LiveInCache" /> - <span>Place VFS in cache</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will place the VFS in the cache directory instead of the config directory. You will need to manually move your VFS root if you plan to keep it when toggling this setting.</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_AttachRoot" /> @@ -1622,6 +1615,19 @@ <h3>Experimental Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Enabling this will attach a physical VFS "root" to your libraries as a media folder, allowing us to mount the VFS children to the new "root" and to re-use media folders across libraries without resorting to <i>other</i> workarounds.</div> </div> + <div id="VFS_LocationContainer" class="selectContainer selectContainer-withDescription expert-only"> + <label class="selectLabel" for="VFS_Location">VFS Location:</label> + <select is="emby-select" id="VFS_Location" name="VFS_Location" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Jellyfin Data Directory (Default)</option> + <option value="Cache">Jellyfin Cache Directory</option> + <option value="Custom">Custom Directory</option> + </select> + <div class="fieldDescription">Determines where the VFS root will be placed. Changing this will cause your library to "remove" and "re-add" itself becaue of the path changes. You will need to manually move your VFS root if you plan to keep it when toggling this setting, or you can get rid of it by running the scheduled task to remove it. <strong>You have been warned.</strong></div> + </div> + <div id="VFS_CustomLocationContainer" class="inputContainer inputContainer-withDescription expert-only" hidden> + <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location:" disabled /> + <div class="fieldDescription">An absolute path to the custom root directory of where the VFS will be placed. You decide.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 1523dcfe..bb20b431 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -132,7 +132,11 @@ public string VirtualRoot { get { - var virtualRoot = _virtualRoot ??= Path.Join(Configuration.VFS_LiveInCache ? ApplicationPaths.CachePath : ApplicationPaths.ProgramDataPath, Name); + var virtualRoot = _virtualRoot ??= Configuration.VFS_Location switch { + VirtualRootLocation.Custom => VirtualRoot_Custom ?? VirtualRoot_Default, + VirtualRootLocation.Cache => VirtualRoot_Cache, + VirtualRootLocation.Default or _ => VirtualRoot_Default, + }; if (!Directory.Exists(virtualRoot)) Directory.CreateDirectory(virtualRoot); @@ -140,6 +144,12 @@ public string VirtualRoot } } + public string VirtualRoot_Default => Path.Join(ApplicationPaths.ProgramDataPath, Name); + + public string VirtualRoot_Cache => Path.Join(ApplicationPaths.CachePath, Name); + + public string? VirtualRoot_Custom => string.IsNullOrWhiteSpace(Configuration.VFS_CustomLocation) ? null : Path.Combine(ApplicationPaths.ProgramDataPath, Configuration.VFS_CustomLocation); + /// <summary> /// Gets or sets the event handler that is triggered when this configuration changes. /// </summary> @@ -197,6 +207,10 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) { if (e is not PluginConfiguration config) return; + if (string.IsNullOrWhiteSpace(config.VFS_CustomLocation) && config.VFS_CustomLocation is not null) { + config.VFS_CustomLocation = null; + SaveConfiguration(config); + } IgnoredFolders = config.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); _virtualRoot = null; diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index 379a63f7..b73e6900 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -56,6 +56,23 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat return Task.CompletedTask; var start = DateTime.Now; + var virtualRoots = new string[] { + Plugin.Instance.VirtualRoot_Default, + Plugin.Instance.VirtualRoot_Cache, + Plugin.Instance.VirtualRoot_Custom ?? string.Empty, + } + .Except([Plugin.Instance.VirtualRoot, string.Empty]) + .Where(Directory.Exists) + .ToList(); + Logger.LogDebug("Found {RemoveCount} VFS roots to remove.", virtualRoots.Count); + foreach (var virtualRoot in virtualRoots) { + var folderStart = DateTime.Now; + Logger.LogTrace("Removing VFS root {Path}.", virtualRoot); + Directory.Delete(virtualRoot, true); + var perFolderDeltaTime = DateTime.Now - folderStart; + Logger.LogTrace("Removed VFS root {Path} in {TimeSpan}.", virtualRoot, perFolderDeltaTime); + } + var mediaFolders = Plugin.Instance.Configuration.MediaFolders.ToList() .Select(config => config.LibraryId.ToString()) .Distinct() @@ -63,13 +80,13 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat var vfsRoots = FileSystem.GetDirectories(Plugin.Instance.VirtualRoot, false) .ExceptBy(mediaFolders, directoryInfo => directoryInfo.Name) .ToList(); - Logger.LogDebug("Found {RemoveCount} VFS roots to remove.", vfsRoots.Count); + Logger.LogDebug("Found {RemoveCount} VFS library roots to remove.", vfsRoots.Count); foreach (var vfsRoot in vfsRoots) { var folderStart = DateTime.Now; - Logger.LogTrace("Removing VFS root for {Id}.", vfsRoot.Name); + Logger.LogTrace("Removing VFS library root for {Id}.", vfsRoot.Name); Directory.Delete(vfsRoot.FullName, true); var perFolderDeltaTime = DateTime.Now - folderStart; - Logger.LogTrace("Removed VFS root for {Id} in {TimeSpan}.", vfsRoot.Name, perFolderDeltaTime); + Logger.LogTrace("Removed VFS library root for {Id} in {TimeSpan}.", vfsRoot.Name, perFolderDeltaTime); } var deltaTime = DateTime.Now - start; From ca2583a26d0614282a92d5931d3878fe11546ed5 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 02:34:04 +0200 Subject: [PATCH 077/144] misc: add missing expert mode class to merge seasons option --- Shokofin/Configuration/configPage.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index 5d232c9e..e1de5c68 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1628,7 +1628,7 @@ <h3>Experimental Settings</h3> <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location:" disabled /> <div class="fieldDescription">An absolute path to the custom root directory of where the VFS will be placed. You decide.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> <span>Automatically merge seasons</span> From 0a9de511f7fd96f55e9f840151220f846d5b238a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 03:11:01 +0200 Subject: [PATCH 078/144] fix: properly generate VFS for movie libraries - Properly generate the VFS for movie libraries, even when filtering is disabled. --- Shokofin/Resolvers/VirtualFileSystemService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index e017112d..20c65015 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -740,7 +740,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { var filePartSuffix = (episodeXref.Percentage?.Group ?? 1) is not 1 ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Group == episodeXref.Percentage!.Group).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" : ""; - if (isMovieSeason && collectionType is not CollectionType.tvshows) { + if (collectionType is CollectionType.movies && !config.FilterMovieLibraries || isMovieSeason && collectionType is null) { if (extrasFolders != null) { foreach (var extrasFolder in extrasFolders) foreach (var episodeInfo in season.EpisodeList.Where(a => a.Shoko.Size > 0)) From fa6b9e131ff772c28aa77b53a9a09abc6e316034 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 03:13:16 +0200 Subject: [PATCH 079/144] refactor: generate all configurations first - Generate all configurations first, then just extract the needed one, instead of first generating one configuration, then generating the rest, then returning the first. --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 6b1f3c6b..6bf178fe 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -257,13 +257,10 @@ public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder me if (allVirtualFolders.FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path) && (collectionType is CollectionType.unknown || p.CollectionType.ConvertToCollectionType() == collectionType)) is not { } library || !Guid.TryParse(library.ItemId, out var libraryId)) throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); - var config = Plugin.Instance.Configuration; - var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); - var mediaFolderConfig = config.MediaFolders.FirstOrDefault(c => c.MediaFolderId == mediaFolder.Id && c.LibraryId == libraryId) ?? - CreateConfigurationForPath(libraryId, mediaFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); - GenerateAllConfigurations(allVirtualFolders); + var config = Plugin.Instance.Configuration; + var mediaFolderConfig = config.MediaFolders.First(c => c.MediaFolderId == mediaFolder.Id && c.LibraryId == libraryId); return mediaFolderConfig; } } From 47d0a411ee537379429dd2f1bb9c17418184277c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 03:54:09 +0200 Subject: [PATCH 080/144] fix: attach import folder relative path - Attach the import folder's relative path to the file's relative path when comparing the locations in the shoko library monitor to determine if the file is known by shoko. --- Shokofin/Resolvers/ShokoLibraryMonitor.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Resolvers/ShokoLibraryMonitor.cs b/Shokofin/Resolvers/ShokoLibraryMonitor.cs index c65b1fef..9bb0ee4a 100644 --- a/Shokofin/Resolvers/ShokoLibraryMonitor.cs +++ b/Shokofin/Resolvers/ShokoLibraryMonitor.cs @@ -280,7 +280,7 @@ await Cache.GetOrCreateAsync( var trackerId = Plugin.Instance.Tracker.Add($"Library Monitor: Path=\"{path}\""); try { var files = await ApiClient.GetFileByPath(relativePath); - var file = files.FirstOrDefault(file => file.Locations.Any(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath)); + var file = files.FirstOrDefault(file => file.Locations.Any(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == mediaConfig.ImportFolderRelativePath + relativePath)); if (file is null) { if (reason is not UpdateReason.Removed) { Logger.LogTrace("Skipped path because it is not a shoko managed file; {Path}", path); @@ -298,7 +298,7 @@ await Cache.GetOrCreateAsync( file = await ApiClient.GetFile(fileId); } - var fileLocation = file.Locations.First(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == relativePath); + var fileLocation = file.Locations.First(location => location.ImportFolderId == mediaConfig.ImportFolderId && location.RelativePath == mediaConfig.ImportFolderRelativePath + relativePath); eventArgs = new FileEventArgsStub(fileLocation, file); } // which we catch here. From 53436a2564c77ebf8bd3f439acc1af3baa9ec9b3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 03:55:18 +0200 Subject: [PATCH 081/144] fix: fix up signalr media folder selector + more - Fixed up the signalr media folder settings selector. - Fixed loading indicator. --- Shokofin/Configuration/configController.js | 46 +++++++++++++--------- 1 file changed, 27 insertions(+), 19 deletions(-) diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js index 6746e84a..98ff643a 100644 --- a/Shokofin/Configuration/configController.js +++ b/Shokofin/Configuration/configController.js @@ -112,7 +112,6 @@ async function loadUserConfig(form, userId, config) { if (!userId) { form.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); form.querySelector("#UserUsername").removeAttribute("required"); - Dashboard.hideLoadingMsg(); return; } @@ -162,7 +161,6 @@ async function loadMediaFolderConfig(form, selectedValue, config) { if (!mediaFolderId || !libraryId) { form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - Dashboard.hideLoadingMsg(); return; } @@ -199,23 +197,29 @@ async function loadMediaFolderConfig(form, selectedValue, config) { } } -async function loadSignalrMediaFolderConfig(form, mediaFolderId, config) { - if (!mediaFolderId) { +async function loadSignalrMediaFolderConfig(form, selectedValue, config) { + const [mediaFolderId, libraryId] = selectedValue.split(","); + if (!mediaFolderId || !libraryId) { form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").removeAttribute("hidden"); form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - Dashboard.hideLoadingMsg(); return; } - Dashboard.showLoadingMsg(); - // Get the configuration to use. - if (!config) config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId) - const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId); + let shouldHide = false; + if (!config) { + Dashboard.showLoadingMsg(); + config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); + shouldHide = true; + } + + const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId && libraryId === c.LibraryId); if (!mediaFolderConfig) { form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").removeAttribute("hidden"); form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - Dashboard.hideLoadingMsg(); + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } return; } @@ -229,7 +233,9 @@ async function loadSignalrMediaFolderConfig(form, mediaFolderId, config) { form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); - Dashboard.hideLoadingMsg(); + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } } /** @@ -338,8 +344,8 @@ async function defaultSubmit(form) { config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; // Media Folder settings - let mediaFolderId = form.querySelector("#MediaFolderSelector").value; - let mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + let [mediaFolderId, libraryId] = form.querySelector("#MediaFolderSelector").value.split(","); + let mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; config.IgnoredFolders = ignoredFolders; form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; @@ -365,8 +371,8 @@ async function defaultSubmit(form) { config.SignalR_AutoReconnectInSeconds = reconnectIntervals; form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); - mediaFolderId = form.querySelector("#SignalRMediaFolderSelector").value; - mediaFolderConfig = mediaFolderId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId) : undefined; + ([mediaFolderId, libraryId] = form.querySelector("#SignalRMediaFolderSelector").value.split(",")); + mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; if (mediaFolderConfig) { const libraryId = mediaFolderConfig.LibraryId; for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { @@ -729,7 +735,7 @@ export default function (page) { const signalrMediaFolderSelector = form.querySelector("#SignalRMediaFolderSelector"); // Refresh the view after we changed the settings, so the view reflect the new settings. - const refreshSettings = (config) => { + const refreshSettings = async (config) => { if (config.ExpertMode) { form.classList.add("expert-mode"); } @@ -786,9 +792,11 @@ export default function (page) { form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); } - loadUserConfig(form, form.querySelector("#UserSelector").value, config); - loadMediaFolderConfig(form, form.querySelector("#MediaFolderSelector").value, config); - loadSignalrMediaFolderConfig(form, form.querySelector("#SignalRMediaFolderSelector").value, config); + await loadUserConfig(form, form.querySelector("#UserSelector").value, config); + await loadMediaFolderConfig(form, form.querySelector("#MediaFolderSelector").value, config); + await loadSignalrMediaFolderConfig(form, form.querySelector("#SignalRMediaFolderSelector").value, config); + + Dashboard.hideLoadingMsg(); }; /** From b500f26bde85e0b1477f43c16891d4917d5bb206 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 03:58:12 +0200 Subject: [PATCH 082/144] misc: enable file events by default - Enable file events by default. Because of the way we do the event handling then the setting also applies to real-time monitoring events for the library/libraries. --- Shokofin/Configuration/PluginConfiguration.cs | 2 +- Shokofin/Configuration/configPage.html | 10 +++++----- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index ced5b6f4..c61308ff 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -620,7 +620,7 @@ public PluginConfiguration() SignalR_AutoReconnectInSeconds = [0, 2, 10, 30, 60, 120, 300]; SignalR_EventSources = [ProviderName.Shoko, ProviderName.AniDB, ProviderName.TMDB]; SignalR_RefreshEnabled = false; - SignalR_FileEvents = false; + SignalR_FileEvents = true; UsageTracker_StalledTimeInSeconds = 10; EXPERIMENTAL_MergeSeasons = false; EXPERIMENTAL_MergeSeasonsTypes = [SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA]; diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index e1de5c68..cbefecb8 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1482,7 +1482,7 @@ <h3 class="listItemBodyText">TMDB</h3> <span>File Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enable the SignalR file events for any new media folders.</div> + <div>Enables SignalR file events and real-time monitoring events for any new media folders.</div> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> @@ -1491,7 +1491,7 @@ <h3 class="listItemBodyText">TMDB</h3> <span>Metadata Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enable the SignalR metadata update events for any new media folders.</div> + <div>Enables SignalR metadata update events for any new media folders.</div> </div> </div> </div> @@ -1506,16 +1506,16 @@ <h3 class="listItemBodyText">TMDB</h3> <span>File Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enable the SignalR file events for the media folder.</div> + <div>Enables SignalR file events and real-time monitoring events for the media folder.</div> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SignalRRefreshEvents" /> - <span>Refresh Events</span> + <span>Metadata Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enable the SignalR metadata update events for the media folder.</div> + <div>Enables SignalR metadata update events for the media folder.</div> </div> </div> </div> From 68481905e848acdc5b24afba7e05dc707931b862 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 25 Sep 2024 04:23:50 +0200 Subject: [PATCH 083/144] feat: merge versions upon refresh --- Shokofin/MergeVersions/MergeVersionManager.cs | 102 ++++++++++++++---- Shokofin/Providers/CustomEpisodeProvider.cs | 38 ++++--- Shokofin/Providers/CustomMovieProvider.cs | 49 +++++++++ Shokofin/Providers/CustomSeasonProvider.cs | 43 ++++++-- Shokofin/Providers/CustomSeriesProvider.cs | 32 +++++- 5 files changed, 215 insertions(+), 49 deletions(-) create mode 100644 Shokofin/Providers/CustomMovieProvider.cs diff --git a/Shokofin/MergeVersions/MergeVersionManager.cs b/Shokofin/MergeVersions/MergeVersionManager.cs index d51854cb..82c31d30 100644 --- a/Shokofin/MergeVersions/MergeVersionManager.cs +++ b/Shokofin/MergeVersions/MergeVersionManager.cs @@ -10,7 +10,9 @@ using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; using Shokofin.ExternalIds; +using Shokofin.Utils; namespace Shokofin.MergeVersions; @@ -24,25 +26,56 @@ namespace Shokofin.MergeVersions; /// https://github.com/danieladov/jellyfin-plugin-mergeversions public class MergeVersionsManager { + /// <summary> + /// Logger. + /// </summary> + private readonly ILogger<MergeVersionsManager> _logger; + /// <summary> /// Library manager. Used to fetch items from the library. /// </summary> - private readonly ILibraryManager LibraryManager; + private readonly ILibraryManager _libraryManager; /// <summary> /// Shoko ID Lookup. Used to check if the plugin is enabled for the videos. /// </summary> - private readonly IIdLookup Lookup; + private readonly IIdLookup _lookup; + + /// <summary> + /// Used to clear the <see cref="_runGuard"/> when the + /// <see cref="UsageTracker.Stalled"/> event is ran. + /// </summary> + private readonly UsageTracker _usageTracker; + + /// <summary> + /// Used as a lock/guard to prevent multiple runs on the same video until + /// the <see cref="UsageTracker.Stalled"/> event is ran. + /// </summary> + private readonly GuardedMemoryCache _runGuard; /// <summary> /// Used by the DI IoC to inject the needed interfaces. /// </summary> /// <param name="libraryManager">Library manager.</param> /// <param name="lookup">Shoko ID Lookup.</param> - public MergeVersionsManager(ILibraryManager libraryManager, IIdLookup lookup) + public MergeVersionsManager(ILogger<MergeVersionsManager> logger, ILibraryManager libraryManager, IIdLookup lookup, UsageTracker usageTracker) + { + _logger = logger; + _libraryManager = libraryManager; + _lookup = lookup; + _usageTracker = usageTracker; + _usageTracker.Stalled += OnUsageTrackerStalled; + _runGuard = new(logger, new() { }, new() { }); + } + + ~MergeVersionsManager() + { + _usageTracker.Stalled -= OnUsageTrackerStalled; + } + + private void OnUsageTrackerStalled(object? sender, EventArgs e) { - LibraryManager = libraryManager; - Lookup = lookup; + _runGuard.Clear(); } #region Top Level @@ -120,6 +153,9 @@ public async Task SplitAndMergeAllEpisodes(IProgress<double>? progress, Cancella public async Task SplitAllEpisodes(IProgress<double>? progress, CancellationToken? cancellationToken) => await SplitVideos(GetEpisodesFromLibrary(), progress, cancellationToken); + public Task<bool> SplitAndMergeEpisodesByEpisodeId(string episodeId) + => _runGuard.GetOrCreateAsync($"episode:{episodeId}", () => SplitAndMergeVideos(GetEpisodesFromLibrary(episodeId))); + #endregion #region Movie Level @@ -130,6 +166,9 @@ public async Task SplitAndMergeAllMovies(IProgress<double>? progress, Cancellati public async Task SplitAllMovies(IProgress<double>? progress, CancellationToken? cancellationToken) => await SplitVideos(GetMoviesFromLibrary(), progress, cancellationToken); + public Task<bool> SplitAndMergeMoviesByEpisodeId(string movieId) + => _runGuard.GetOrCreateAsync($"movie:{movieId}", () => SplitAndMergeVideos(GetMoviesFromLibrary(movieId))); + #endregion #region Shared Methods @@ -140,7 +179,7 @@ public async Task SplitAllMovies(IProgress<double>? progress, CancellationToken? /// <param name="episodeId">Optional. The episode id if we want to filter to only movies with a given Shoko Episode ID.</param> /// <returns>A list of all movies with the given <paramref name="episodeId"/> set.</returns> public IReadOnlyList<Movie> GetMoviesFromLibrary(string episodeId = "") - => LibraryManager + => _libraryManager .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Movie], IsVirtualItem = false, @@ -148,7 +187,7 @@ public IReadOnlyList<Movie> GetMoviesFromLibrary(string episodeId = "") HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, episodeId } }, }) .OfType<Movie>() - .Where(Lookup.IsEnabledForItem) + .Where(_lookup.IsEnabledForItem) .ToList(); /// <summary> @@ -157,7 +196,7 @@ public IReadOnlyList<Movie> GetMoviesFromLibrary(string episodeId = "") /// <param name="episodeId">Optional. The episode id if we want to filter to only episodes with a given Shoko Episode ID.</param> /// <returns>A list of all episodes with a Shoko Episode ID set.</returns> public IReadOnlyList<Episode> GetEpisodesFromLibrary(string episodeId = "") - => LibraryManager + => _libraryManager .GetItemList(new() { IncludeItemTypes = [BaseItemKind.Episode], HasAnyProviderId = new Dictionary<string, string> { {ShokoEpisodeId.Name, episodeId } }, @@ -165,7 +204,7 @@ public IReadOnlyList<Episode> GetEpisodesFromLibrary(string episodeId = "") Recursive = true, }) .Cast<Episode>() - .Where(Lookup.IsEnabledForItem) + .Where(_lookup.IsEnabledForItem) .ToList(); /// <summary> @@ -175,7 +214,7 @@ public IReadOnlyList<Episode> GetEpisodesFromLibrary(string episodeId = "") /// <param name="cancellationToken">Cancellation token.</param> /// <returns>An async task that will silently complete when the merging is /// complete.</returns> - public async Task SplitAndMergeVideos<TVideo>( + public async Task<bool> SplitAndMergeVideos<TVideo>( IReadOnlyList<TVideo> videos, IProgress<double>? progress = null, CancellationToken? cancellationToken = null @@ -212,6 +251,8 @@ public async Task SplitAndMergeVideos<TVideo>( } progress?.Report(100); + + return true; } /// <summary> @@ -245,7 +286,7 @@ public async Task SplitVideos<TVideo>(IReadOnlyList<TVideo> videos, IProgress<do /// /// Modified from; /// https://github.com/jellyfin/jellyfin/blob/9c97c533eff94d25463fb649c9572234da4af1ea/Jellyfin.Api/Controllers/VideosController.cs#L192 - private static async Task MergeVideos<TVideo>(IEnumerable<TVideo> input) where TVideo : Video + private async Task MergeVideos<TVideo>(IEnumerable<TVideo> input) where TVideo : Video { if (input is not IList<TVideo> videos) videos = input.ToList(); @@ -265,12 +306,12 @@ private static async Task MergeVideos<TVideo>(IEnumerable<TVideo> input) where T .First(); // Add any videos not already linked to the primary version to the list. - var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions - .ToList(); + var alternateVersionsOfPrimary = primaryVersion.LinkedAlternateVersions.ToList(); foreach (var video in videos.Where(v => !v.Id.Equals(primaryVersion.Id))) { video.SetPrimaryVersionId(primaryVersion.Id.ToString("N", CultureInfo.InvariantCulture)); if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, video.Path, StringComparison.OrdinalIgnoreCase))) { + _logger.LogTrace("Adding linked alternate version. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", primaryVersion.Id, video.Id); alternateVersionsOfPrimary.Add(new() { Path = video.Path, ItemId = video.Id, @@ -278,20 +319,25 @@ private static async Task MergeVideos<TVideo>(IEnumerable<TVideo> input) where T } foreach (var linkedItem in video.LinkedAlternateVersions) { - if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) + if (!alternateVersionsOfPrimary.Any(i => string.Equals(i.Path, linkedItem.Path, StringComparison.OrdinalIgnoreCase))) { + _logger.LogTrace("Adding linked alternate version. (PrimaryVideo={PrimaryVideoId},Video={VideoId},LinkedVideo={LinkedVideoId})", primaryVersion.Id, video.Id, linkedItem.ItemId); alternateVersionsOfPrimary.Add(linkedItem); + } } // Reset the linked alternate versions for the linked videos. - if (video.LinkedAlternateVersions.Length > 0) + if (video.LinkedAlternateVersions.Length > 0) { + _logger.LogTrace("Resetting linked alternate versions for video. (Video={VideoId})", video.Id); video.LinkedAlternateVersions = []; + } // Save the changes back to the repository. await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } - primaryVersion.LinkedAlternateVersions = [.. alternateVersionsOfPrimary]; + _logger.LogTrace("Saving {Count} linked alternate versions. (PrimaryVideo={PrimaryVideoId})", alternateVersionsOfPrimary.Count, primaryVersion.Id); + primaryVersion.LinkedAlternateVersions = [.. alternateVersionsOfPrimary.OrderBy(i => i.Path)]; await primaryVersion.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) .ConfigureAwait(false); } @@ -313,14 +359,21 @@ private async Task RemoveAlternateSources<TVideo>(TVideo video) where TVideo : V return; // Make sure the primary video still exists before we proceed. - if (LibraryManager.GetItemById(video.PrimaryVersionId) is not TVideo primaryVideo) + if (_libraryManager.GetItemById(video.PrimaryVersionId) is not TVideo primaryVideo) return; + + _logger.LogTrace("Primary video found for video. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", primaryVideo.Id, video.Id); video = primaryVideo; } // Remove the link for every linked video. - foreach (var linkedVideo in video.GetLinkedAlternateVersions()) - { + var linkedAlternateVersions = video.GetLinkedAlternateVersions().ToList(); + _logger.LogTrace("Removing {Count} alternate sources for video. (Video={VideoId})", linkedAlternateVersions.Count, video.Id); + foreach (var linkedVideo in linkedAlternateVersions) { + if (string.IsNullOrEmpty(linkedVideo.PrimaryVersionId)) + continue; + + _logger.LogTrace("Removing alternate source. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", linkedVideo.PrimaryVersionId, video.Id); linkedVideo.SetPrimaryVersionId(null); linkedVideo.LinkedAlternateVersions = []; await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) @@ -328,10 +381,13 @@ await linkedVideo.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, Cancellat } // Remove the link for the primary video. - video.SetPrimaryVersionId(null); - video.LinkedAlternateVersions = []; - await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) - .ConfigureAwait(false); + if (!string.IsNullOrEmpty(video.PrimaryVersionId)) { + _logger.LogTrace("Removing primary source. (PrimaryVideo={PrimaryVideoId},Video={VideoId})", video.PrimaryVersionId, video.Id); + video.SetPrimaryVersionId(null); + video.LinkedAlternateVersions = []; + await video.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None) + .ConfigureAwait(false); + } } #endregion Shared Methods diff --git a/Shokofin/Providers/CustomEpisodeProvider.cs b/Shokofin/Providers/CustomEpisodeProvider.cs index db244c41..973ae801 100644 --- a/Shokofin/Providers/CustomEpisodeProvider.cs +++ b/Shokofin/Providers/CustomEpisodeProvider.cs @@ -7,13 +7,15 @@ using MediaBrowser.Model.Entities; using Microsoft.Extensions.Logging; using Shokofin.ExternalIds; +using Shokofin.MergeVersions; using Info = Shokofin.API.Info; namespace Shokofin.Providers; /// <summary> -/// The custom episode provider. Responsible for de-duplicating episodes. +/// The custom episode provider. Responsible for de-duplicating episodes, both +/// virtual and physical. /// </summary> /// <remarks> /// This needs to be it's own class because of internal Jellyfin shenanigans @@ -24,32 +26,38 @@ public class CustomEpisodeProvider : ICustomMetadataProvider<Episode> { public string Name => Plugin.MetadataProviderName; - private readonly ILogger<CustomEpisodeProvider> Logger; + private readonly ILogger<CustomEpisodeProvider> _logger; - private readonly IIdLookup Lookup; + private readonly ILibraryManager _libraryManager; - private readonly ILibraryManager LibraryManager; + private readonly MergeVersionsManager _mergeVersionsManager; - public CustomEpisodeProvider(ILogger<CustomEpisodeProvider> logger, IIdLookup lookup, ILibraryManager libraryManager) + public CustomEpisodeProvider(ILogger<CustomEpisodeProvider> logger, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) { - Logger = logger; - Lookup = lookup; - LibraryManager = libraryManager; + _logger = logger; + _libraryManager = libraryManager; + _mergeVersionsManager = mergeVersionsManager; } - public Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) + public async Task<ItemUpdateType> FetchAsync(Episode episode, MetadataRefreshOptions options, CancellationToken cancellationToken) { var series = episode.Series; if (series is null) - return Task.FromResult(ItemUpdateType.None); + return ItemUpdateType.None; - // Abort if we're unable to get the shoko episode id - if (episode.TryGetProviderId(ShokoEpisodeId.Name, out var episodeId)) + var itemUpdated = ItemUpdateType.None; + if (episode.TryGetProviderId(ShokoEpisodeId.Name, out var episodeId)) { using (Plugin.Instance.Tracker.Enter($"Providing custom info for Episode \"{episode.Name}\". (Path=\"{episode.Path}\",IsMissingEpisode={episode.IsMissingEpisode})")) - if (RemoveDuplicates(LibraryManager, Logger, episodeId, episode, series.GetPresentationUniqueKey())) - return Task.FromResult(ItemUpdateType.MetadataEdit); + if (RemoveDuplicates(_libraryManager, _logger, episodeId, episode, series.GetPresentationUniqueKey())) + itemUpdated |= ItemUpdateType.MetadataEdit; - return Task.FromResult(ItemUpdateType.None); + if (Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + await _mergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + itemUpdated |= ItemUpdateType.MetadataEdit; + } + } + + return itemUpdated; } public static bool RemoveDuplicates(ILibraryManager libraryManager, ILogger logger, string episodeId, Episode episode, string seriesPresentationUniqueKey) diff --git a/Shokofin/Providers/CustomMovieProvider.cs b/Shokofin/Providers/CustomMovieProvider.cs new file mode 100644 index 00000000..8f02d4c5 --- /dev/null +++ b/Shokofin/Providers/CustomMovieProvider.cs @@ -0,0 +1,49 @@ +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Entities; +using Microsoft.Extensions.Logging; +using Shokofin.ExternalIds; +using Shokofin.MergeVersions; + +namespace Shokofin.Providers; + +/// <summary> +/// The custom movie provider. Responsible for de-duplicating physical movies. +/// </summary> +/// <remarks> +/// This needs to be it's own class because of internal Jellyfin shenanigans +/// about how a provider cannot also be a custom provider otherwise it won't +/// save the metadata. +/// </remarks> +public class CustomMovieProvider : ICustomMetadataProvider<Movie> +{ + public string Name => Plugin.MetadataProviderName; + + private readonly ILogger<CustomEpisodeProvider> _logger; + + private readonly ILibraryManager _libraryManager; + + private readonly MergeVersionsManager _mergeVersionsManager; + + public CustomMovieProvider(ILogger<CustomEpisodeProvider> logger, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) + { + _logger = logger; + _libraryManager = libraryManager; + _mergeVersionsManager = mergeVersionsManager; + } + + public async Task<ItemUpdateType> FetchAsync(Movie movie, MetadataRefreshOptions options, CancellationToken cancellationToken) + { + var itemUpdated = ItemUpdateType.None; + if (movie.TryGetProviderId(ShokoEpisodeId.Name, out var episodeId) && Plugin.Instance.Configuration.AutoMergeVersions && !_libraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + await _mergeVersionsManager.SplitAndMergeMoviesByEpisodeId(episodeId); + itemUpdated |= ItemUpdateType.MetadataEdit; + } + + return itemUpdated; + } +} \ No newline at end of file diff --git a/Shokofin/Providers/CustomSeasonProvider.cs b/Shokofin/Providers/CustomSeasonProvider.cs index 70f1b021..5675aa5a 100644 --- a/Shokofin/Providers/CustomSeasonProvider.cs +++ b/Shokofin/Providers/CustomSeasonProvider.cs @@ -10,14 +10,15 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.ExternalIds; +using Shokofin.MergeVersions; using Info = Shokofin.API.Info; namespace Shokofin.Providers; /// <summary> -/// The custom season provider. Responsible for de-duplicating seasons and -/// adding/removing "missing" episodes. +/// The custom season provider. Responsible for de-duplicating seasons, +/// adding/removing "missing" episodes, and de-duplicating physical episodes. /// </summary> /// <remarks> /// This needs to be it's own class because of internal Jellyfin shenanigans @@ -36,14 +37,17 @@ public class CustomSeasonProvider : ICustomMetadataProvider<Season> private readonly ILibraryManager LibraryManager; + private readonly MergeVersionsManager MergeVersionsManager; + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; - public CustomSeasonProvider(ILogger<CustomSeasonProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + public CustomSeasonProvider(ILogger<CustomSeasonProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) { Logger = logger; ApiManager = apiManager; Lookup = lookup; LibraryManager = libraryManager; + MergeVersionsManager = mergeVersionsManager; } public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptions options, CancellationToken cancellationToken) @@ -84,12 +88,15 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) { toRemoveEpisodes.Add(episode); - else + } + else { foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); + } + } else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) toRemoveEpisodes.Add(episode); @@ -119,6 +126,13 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio } } } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } // Every other "season." else { @@ -137,12 +151,16 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio var existingEpisodes = new HashSet<string>(); var toRemoveEpisodes = new List<Episode>(); foreach (var episode in season.Children.OfType<Episode>()) { - if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) - if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) + if (Lookup.TryGetEpisodeIdsFor(episode, out var episodeIds)) { + if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Overlaps(episodeIds)) { toRemoveEpisodes.Add(episode); - else + } + else { foreach (var episodeId in episodeIds) existingEpisodes.Add(episodeId); + } + + } else if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { if ((string.IsNullOrEmpty(episode.Path) || episode.IsVirtualItem) && !knownEpisodeIds.Contains(episodeId)) toRemoveEpisodes.Add(episode); @@ -170,6 +188,13 @@ public async Task<ItemUpdateType> FetchAsync(Season season, MetadataRefreshOptio itemUpdated |= ItemUpdateType.MetadataImport; } } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } return itemUpdated; diff --git a/Shokofin/Providers/CustomSeriesProvider.cs b/Shokofin/Providers/CustomSeriesProvider.cs index c6e78a85..205cb572 100644 --- a/Shokofin/Providers/CustomSeriesProvider.cs +++ b/Shokofin/Providers/CustomSeriesProvider.cs @@ -10,12 +10,22 @@ using Microsoft.Extensions.Logging; using Shokofin.API; using Shokofin.ExternalIds; +using Shokofin.MergeVersions; using Shokofin.Utils; using Info = Shokofin.API.Info; namespace Shokofin.Providers; +/// <summary> +/// The custom series provider. Responsible for de-duplicating seasons, +/// adding/removing "missing" episodes, and de-duplicating physical episodes. +/// </summary> +/// <remarks> +/// This needs to be it's own class because of internal Jellyfin shenanigans +/// about how a provider cannot also be a custom provider otherwise it won't +/// save the metadata. +/// </remarks> public class CustomSeriesProvider : ICustomMetadataProvider<Series> { public string Name => Plugin.MetadataProviderName; @@ -28,14 +38,17 @@ public class CustomSeriesProvider : ICustomMetadataProvider<Series> private readonly ILibraryManager LibraryManager; + private readonly MergeVersionsManager MergeVersionsManager; + private static bool ShouldAddMetadata => Plugin.Instance.Configuration.AddMissingMetadata; - public CustomSeriesProvider(ILogger<CustomSeriesProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager) + public CustomSeriesProvider(ILogger<CustomSeriesProvider> logger, ShokoAPIManager apiManager, IIdLookup lookup, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) { Logger = logger; ApiManager = apiManager; Lookup = lookup; LibraryManager = libraryManager; + MergeVersionsManager = mergeVersionsManager; } public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptions options, CancellationToken cancellationToken) @@ -144,7 +157,7 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio } // Add missing episodes. - if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) + if (ShouldAddMetadata && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { foreach (var seasonInfo in showInfo.SeasonList) { foreach (var episodeId in await ApiManager.GetLocalEpisodeIdsForSeason(seasonInfo)) existingEpisodes.Add(episodeId); @@ -157,6 +170,14 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio itemUpdated |= ItemUpdateType.MetadataImport; } } + } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } // All other seasons. @@ -214,6 +235,13 @@ public async Task<ItemUpdateType> FetchAsync(Series series, MetadataRefreshOptio itemUpdated |= ItemUpdateType.MetadataImport; } } + + // Merge versions. + if (Plugin.Instance.Configuration.AutoMergeVersions && !LibraryManager.IsScanRunning && options.MetadataRefreshMode != MetadataRefreshMode.ValidationOnly) { + foreach (var episodeId in existingEpisodes) { + await MergeVersionsManager.SplitAndMergeEpisodesByEpisodeId(episodeId); + } + } } return itemUpdated; From befd5a0ec70e98ba8240d223df7e0b30796fc294 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Sep 2024 03:23:57 +0200 Subject: [PATCH 084/144] misc: fix wording for VFS setting --- Shokofin/Configuration/configPage.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Configuration/configPage.html index cbefecb8..d5db839f 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Configuration/configPage.html @@ -1308,7 +1308,7 @@ <h3>Media Folder Settings</h3> <span>Virtual File System (<strong>VFS</strong>)</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables the use of the Virtual File System™ for any new media libraries managed by the plugin.</div> + <div>Enables the use of the VFS for any new media libraries managed by the plugin.</div> <div id="WindowsSymLinkWarning1" hidden><strong>Warning</strong>: Windows users are required to <a href="https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging#use-regedit-to-enable-your-device" target="_blank" rel="noopener noreferrer">enable Developer Mode</a> then restart Jellyfin to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does this mean?</summary> @@ -1350,10 +1350,10 @@ <h3>Media Folder Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MediaFolderVirtualFileSystem" /> - <span>Virtual File System™</span> + <span>Virtual File System (<strong>VFS</strong>)</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables the use of the Virtual File System™ for the library.</div> + <div>Enables the use of the VFS for the library.</div> <div id="WindowsSymLinkWarning2" hidden><strong>Warning</strong>: Windows users are required to <a href="https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging#use-regedit-to-enable-your-device" target="_blank" rel="noopener noreferrer">enable Developer Mode</a> then restart Jellyfin to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does this mean?</summary> From 75a0e8a44420bbb4c2658102f6451b3cb4fed84f Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 26 Sep 2024 08:04:35 +0200 Subject: [PATCH 085/144] refactor: rewrite settings page (again) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Rewrote the settings controller to support a tab-based view. And in the process tweaked where a few settings belonged. This is purely a client-side/cosmetic change, but helps navigating the settings. - Added a dummy utility to better understand how to implement future utilities. - Added a common JS file with shared logic to document the life-cycle events for plugin views used by the existing settings and dummy utility, in addition to any future utilities. This is also complete with JSDoc type definitions. - Added an option to show the Shoko settings and utilities directly on the side-navigation menu on the dashboard. Saving you 1 step to tweak the settings ~~or use any of the future utilities that may or may not be made at some point in time… no promises~~. - Moved the html/js files to a new directory better suited for storing them. --- Shokofin/Configuration/PluginConfiguration.cs | 10 + Shokofin/Configuration/configController.js | 1272 ----------------- Shokofin/Pages/Dummy.html | 7 + Shokofin/Pages/Scripts/Common.js | 1132 +++++++++++++++ Shokofin/Pages/Scripts/Dummy.js | 38 + Shokofin/Pages/Scripts/Settings.js | 1222 ++++++++++++++++ Shokofin/Pages/Scripts/jsconfig.json | 17 + .../configPage.html => Pages/Settings.html} | 530 ++++--- Shokofin/Plugin.cs | 31 +- Shokofin/Shokofin.csproj | 6 +- 10 files changed, 2767 insertions(+), 1498 deletions(-) delete mode 100644 Shokofin/Configuration/configController.js create mode 100644 Shokofin/Pages/Dummy.html create mode 100644 Shokofin/Pages/Scripts/Common.js create mode 100644 Shokofin/Pages/Scripts/Dummy.js create mode 100644 Shokofin/Pages/Scripts/Settings.js create mode 100644 Shokofin/Pages/Scripts/jsconfig.json rename Shokofin/{Configuration/configPage.html => Pages/Settings.html} (87%) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index c61308ff..1a5b16f6 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -492,6 +492,15 @@ public DescriptionProvider[] ThirdPartyIdProviderList #endregion + #region Misc. features + + /// <summary> + /// Show the plugin in the side-menu. + /// </summary> + public bool Misc_ShowInMenu { get; set; } + + #endregion + #region Experimental features /// <summary> @@ -622,6 +631,7 @@ public PluginConfiguration() SignalR_RefreshEnabled = false; SignalR_FileEvents = true; UsageTracker_StalledTimeInSeconds = 10; + Misc_ShowInMenu = false; EXPERIMENTAL_MergeSeasons = false; EXPERIMENTAL_MergeSeasonsTypes = [SeriesType.OVA, SeriesType.TV, SeriesType.TVSpecial, SeriesType.Web, SeriesType.OVA]; EXPERIMENTAL_MergeSeasonsMergeWindowInDays = 185; diff --git a/Shokofin/Configuration/configController.js b/Shokofin/Configuration/configController.js deleted file mode 100644 index 98ff643a..00000000 --- a/Shokofin/Configuration/configController.js +++ /dev/null @@ -1,1272 +0,0 @@ -const PluginConfig = { - pluginId: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052" -}; - -const Messages = { - ExpertModeCountdown: "Press <count> more times to <toggle> expert mode.", - ExpertModeEnabled: "Expert mode enabled.", - ExpertModeDisabled: "Expert mode disabled.", - ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", - ConnectedToShoko: "Connection established.", - DisconnectedToShoko: "Connection reset.", - InvalidCredentials: "An error occurred while trying to authenticating the user using the provided credentials.", - UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it.", -}; - -/** - * Filter out duplicate values and sanitize list. - * @param {string} value - Stringified list of values to filter. - * @returns {string[]} An array of sanitized and filtered values. - */ -function filterIgnoredFolders(value) { - // We convert to a set to filter out duplicate values. - const filteredSet = new Set( - value - // Split the values at every comma. - .split(",") - // Sanitize inputs. - .map(str => str.trim().toLowerCase()) - .filter(str => str), - ); - - // Convert it back into an array. - return Array.from(filteredSet); -} - -/** - * Filter out duplicate values and sanitize list. - * @param {string} value - Stringified list of values to filter. - * @returns {number[]} An array of sanitized and filtered values. - */ -function filterReconnectIntervals(value) { - // We convert to a set to filter out duplicate values. - const filteredSet = new Set( - value - // Split the values at every comma. - .split(",") - // Sanitize inputs. - .map(str => parseInt(str.trim().toLowerCase(), 10)) - .filter(int => !Number.isNaN(int)), - ); - - // Convert it back into an array. - return Array.from(filteredSet).sort((a, b) => a - b); -} - -/** - * - * @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 { - 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 - **/ -function onSortableContainerClick(event) { - 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"); - const list = parentWithClass(listItem, "paperList"); - if (btnSortable.classList.contains("btnSortableMoveDown")) { - const next = listItem.nextElementSibling; - if (next) { - listItem.parentElement.removeChild(listItem); - next.parentElement.insertBefore(listItem, next.nextSibling); - } - } - else { - const prev = listItem.previousElementSibling; - if (prev) { - listItem.parentElement.removeChild(listItem); - prev.parentElement.insertBefore(listItem, prev); - } - } - let i = 0; - for (const option of list.querySelectorAll(".sortableOption")) { - adjustSortableListElement(option, i++); - } - } -} - -async function loadUserConfig(form, userId, config) { - if (!userId) { - form.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); - form.querySelector("#UserUsername").removeAttribute("required"); - return; - } - - // Get the configuration to use. - let shouldHide = false; - if (!config) { - Dashboard.showLoadingMsg(); - config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - shouldHide = true; - } - - // Configure the elements within the user container - const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; - form.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; - form.querySelector("#SyncUserDataOnImport").checked = userConfig.SyncUserDataOnImport || false; - form.querySelector("#SyncUserDataAfterPlayback").checked = userConfig.SyncUserDataAfterPlayback || false; - form.querySelector("#SyncUserDataUnderPlayback").checked = userConfig.SyncUserDataUnderPlayback || false; - form.querySelector("#SyncUserDataUnderPlaybackLive").checked = userConfig.SyncUserDataUnderPlaybackLive || false; - form.querySelector("#SyncUserDataInitialSkipEventCount").checked = userConfig.SyncUserDataInitialSkipEventCount === 2; - form.querySelector("#SyncRestrictedVideos").checked = userConfig.SyncRestrictedVideos || false; - form.querySelector("#UserUsername").value = userConfig.Username || ""; - // Synchronization settings - form.querySelector("#UserPassword").value = ""; - if (userConfig.Token) { - form.querySelector("#UserDeleteContainer").removeAttribute("hidden"); - form.querySelector("#UserUsername").setAttribute("disabled", ""); - form.querySelector("#UserPasswordContainer").setAttribute("hidden", ""); - form.querySelector("#UserUsername").removeAttribute("required"); - } - else { - form.querySelector("#UserDeleteContainer").setAttribute("hidden", ""); - form.querySelector("#UserUsername").removeAttribute("disabled"); - form.querySelector("#UserPasswordContainer").removeAttribute("hidden"); - form.querySelector("#UserUsername").setAttribute("required", ""); - } - - // Show the user settings now if it was previously hidden. - form.querySelector("#UserSettingsContainer").removeAttribute("hidden"); - - if (shouldHide) { - Dashboard.hideLoadingMsg(); - } -} - -async function loadMediaFolderConfig(form, selectedValue, config) { - const [mediaFolderId, libraryId] = selectedValue.split(","); - if (!mediaFolderId || !libraryId) { - form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); - form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - return; - } - - // Get the configuration to use. - let shouldHide = false; - if (!config) { - Dashboard.showLoadingMsg(); - config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - shouldHide = true; - } - - const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId && libraryId === c.LibraryId); - if (!mediaFolderConfig) { - form.querySelector("#MediaFolderDefaultSettingsContainer").removeAttribute("hidden"); - form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - if (shouldHide) { - Dashboard.hideLoadingMsg(); - } - return; - } - - form.querySelector("#MediaFolderImportFolderName").value = mediaFolderConfig.IsMapped ? `${mediaFolderConfig.ImportFolderName} (${mediaFolderConfig.ImportFolderId}) ${mediaFolderConfig.ImportFolderRelativePath}` : "Not Mapped"; - - // Configure the elements within the user container - form.querySelector("#MediaFolderVirtualFileSystem").checked = mediaFolderConfig.IsVirtualFileSystemEnabled; - form.querySelector("#MediaFolderLibraryFilteringMode").value = mediaFolderConfig.LibraryFilteringMode; - - // Show the user settings now if it was previously hidden. - form.querySelector("#MediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); - form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); - - if (shouldHide) { - Dashboard.hideLoadingMsg(); - } -} - -async function loadSignalrMediaFolderConfig(form, selectedValue, config) { - const [mediaFolderId, libraryId] = selectedValue.split(","); - if (!mediaFolderId || !libraryId) { - form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").removeAttribute("hidden"); - form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - return; - } - - // Get the configuration to use. - let shouldHide = false; - if (!config) { - Dashboard.showLoadingMsg(); - config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - shouldHide = true; - } - - const mediaFolderConfig = config.MediaFolders.find((c) => mediaFolderId === c.MediaFolderId && libraryId === c.LibraryId); - if (!mediaFolderConfig) { - form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").removeAttribute("hidden"); - form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); - if (shouldHide) { - Dashboard.hideLoadingMsg(); - } - return; - } - - form.querySelector("#SignalRMediaFolderImportFolderName").value = mediaFolderConfig.IsMapped ? `${mediaFolderConfig.ImportFolderName} (${mediaFolderConfig.ImportFolderId}) ${mediaFolderConfig.ImportFolderRelativePath}` : "Not Mapped"; - - // Configure the elements within the user container - form.querySelector("#SignalRFileEvents").checked = mediaFolderConfig.IsFileEventsEnabled; - form.querySelector("#SignalRRefreshEvents").checked = mediaFolderConfig.IsRefreshEventsEnabled; - - // Show the user settings now if it was previously hidden. - form.querySelector("#SignalRMediaFolderDefaultSettingsContainer").setAttribute("hidden", ""); - form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); - - if (shouldHide) { - Dashboard.hideLoadingMsg(); - } -} - -/** - * - * @param {string} username - * @param {string} password - * @param {boolean?} userKey - * @returns {Promise<{ apikey: string; }>} - */ -function getApiKey(username, password, userKey = false) { - return ApiClient.fetch({ - dataType: "json", - data: JSON.stringify({ - username, - password, - userKey, - }), - headers: { - "Content-Type": "application/json", - "Accept": "application/json", - }, - type: "POST", - url: ApiClient.getUrl("Plugin/Shokofin/Host/GetApiKey"), - }); -} - -/** - * - * @returns {Promise<{ IsUsable: boolean; IsActive: boolean; State: "Disconnected" | "Connected" | "Connecting" | "Reconnecting" }>} - */ -function getSignalrStatus() { - return ApiClient.fetch({ - dataType: "json", - type: "GET", - url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Status"), - }); -} - -async function signalrConnect() { - await ApiClient.fetch({ - type: "POST", - url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Connect"), - }); - return getSignalrStatus(); -} - -async function signalrDisconnect() { - await ApiClient.fetch({ - type: "POST", - url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Disconnect"), - }); - return getSignalrStatus(); -} - -async function defaultSubmit(form) { - let config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - - if (config.ApiKey !== "") { - const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); - - // Metadata settings - 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.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - 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.TagMaximumDepth = parseInt(form.querySelector("#TagMaximumDepth").value, 10); - 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.GenreMaximumDepth = parseInt(form.querySelector("#GenreMaximumDepth").value, 10); - 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.ThirdPartyIdProviderList = retrieveSimpleList(form, "ThirdPartyIdProviderList"); - - // Library settings - config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; - config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; - config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; - config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; - config.CollectionMinSizeOfTwo = form.querySelector("#CollectionMinSizeOfTwo").checked; - config.SeparateMovies = form.querySelector("#SeparateMovies").checked; - config.FilterMovieLibraries = form.querySelector("#FilterMovieLibraries").checked; - config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; - config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; - config.AddTrailers = form.querySelector("#AddTrailers").checked; - config.AddCreditsAsThemeVideos = form.querySelector("#AddCreditsAsThemeVideos").checked; - config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; - config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; - - // Media Folder settings - let [mediaFolderId, libraryId] = form.querySelector("#MediaFolderSelector").value.split(","); - let mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; - config.IgnoredFolders = ignoredFolders; - form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); - config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; - config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; - config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; - config.VFS_Location = form.querySelector("#VFS_Location").value; - config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; - if (mediaFolderConfig) { - const libraryId = mediaFolderConfig.LibraryId; - for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { - c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; - } - } - else { - config.VFS_Enabled = form.querySelector("#VFS_Enabled").checked; - config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; - } - - // SignalR settings - const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); - config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; - config.SignalR_AutoReconnectInSeconds = reconnectIntervals; - form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); - config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); - ([mediaFolderId, libraryId] = form.querySelector("#SignalRMediaFolderSelector").value.split(",")); - mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; - if (mediaFolderConfig) { - const libraryId = mediaFolderConfig.LibraryId; - for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { - c.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; - c.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; - } - } - else { - config.SignalR_FileEvents = form.querySelector("#SignalRDefaultFileEvents").checked; - config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; - } - - - // Experimental settings - config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; - - // User settings - const userId = form.querySelector("#UserSelector").value; - if (userId) { - let userConfig = config.UserList.find((c) => userId === c.UserId); - if (!userConfig) { - userConfig = { UserId: userId }; - config.UserList.push(userConfig); - } - - // The user settings goes below here; - userConfig.EnableSynchronization = form.querySelector("#UserEnableSynchronization").checked; - userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; - userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; - userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; - userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; - userConfig.SyncUserDataInitialSkipEventCount = form.querySelector("#SyncUserDataInitialSkipEventCount").checked ? 2 : 0; - userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; - userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s - userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; - - // Only try to save a new token if a token is not already present. - if (!userConfig.Token) try { - const username = form.querySelector("#UserUsername").value; - const password = form.querySelector("#UserPassword").value; - const response = await getApiKey(username, password, true); - userConfig.Username = username; - userConfig.Token = response.apikey; - } - catch (err) { - Dashboard.alert(Messages.InvalidCredentials); - console.error(err, Messages.InvalidCredentials); - userConfig.Username = ""; - userConfig.Token = ""; - } - } - - let result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); - } - else { - // Connection settings - let url = form.querySelector("#Url").value; - if (!url) { - url = "http://localhost:8111"; - } - else { - try { - let actualUrl = new URL(url); - url = actualUrl.href; - } - catch (err) { - try { - let actualUrl = new URL(`http://${url}:8111`); - url = actualUrl.href; - } - catch (err2) { - throw err; - } - } - } - if (url.endsWith("/")) { - url = url.slice(0, -1); - } - let publicUrl = form.querySelector("#PublicUrl").value; - if (publicUrl.endsWith("/")) { - publicUrl = publicUrl.slice(0, -1); - } - - // Update the url if needed. - if (config.Url !== url || config.PublicUrl !== publicUrl) { - config.Url = url; - config.PublicUrl = publicUrl; - form.querySelector("#Url").value = url; - form.querySelector("#PublicUrl").value = publicUrl; - await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - } - - const username = form.querySelector("#Username").value; - const password = form.querySelector("#Password").value; - try { - const response = await getApiKey(username, password); - config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - config.Username = username; - config.ApiKey = response.apikey; - - await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - - Dashboard.hideLoadingMsg(); - Dashboard.alert(Messages.ConnectedToShoko); - } - catch (err) { - Dashboard.hideLoadingMsg(); - Dashboard.alert(Messages.InvalidCredentials); - console.error(err, Messages.InvalidCredentials); - } - } - - return config; -} - -async function resetConnectionSettings(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - form.querySelector("#Username").value = config.Username; - form.querySelector("#Password").value = ""; - - // Connection settings - config.ApiKey = ""; - config.ServerVersion = null; - - await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - - Dashboard.hideLoadingMsg(); - Dashboard.alert(Messages.DisconnectedToShoko); - - return config; -} - -async function syncSettings(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - - // Metadata settings - 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.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - 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.TagMaximumDepth = parseInt(form.querySelector("#TagMaximumDepth").value, 10); - 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.GenreMaximumDepth = parseInt(form.querySelector("#GenreMaximumDepth").value, 10); - 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.ThirdPartyIdProviderList = retrieveSimpleList(form, "ThirdPartyIdProviderList"); - - // Library settings - config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; - config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; - config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; - config.SeparateMovies = form.querySelector("#SeparateMovies").checked; - config.FilterMovieLibraries = form.querySelector("#FilterMovieLibraries").checked; - config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; - config.CollectionMinSizeOfTwo = form.querySelector("#CollectionMinSizeOfTwo").checked; - config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; - config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; - config.AddTrailers = form.querySelector("#AddTrailers").checked; - config.AddCreditsAsThemeVideos = form.querySelector("#AddCreditsAsThemeVideos").checked; - config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; - config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; - - // Experimental settings - config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; - config.VFS_Location = form.querySelector("#VFS_Location").value; - config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; - config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; - - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); - - return config; -} - -async function unlinkUser(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const userId = form.querySelector("#UserSelector").value; - if (!userId) return; - - const index = config.UserList.findIndex(c => userId === c.UserId); - if (index !== -1) { - config.UserList.splice(index, 1); - } - - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); - - return config; -} - -async function removeMediaFolder(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const [mediaFolderId, libraryId] = form.querySelector("#MediaFolderSelector").value.value.split(","); - if (!mediaFolderId || !libraryId) return; - - const index = config.MediaFolders.findIndex((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId); - if (index !== -1) { - config.MediaFolders.splice(index, 1); - } - - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - form.querySelector("#MediaFolderSelector").value = ""; - form.querySelector("#MediaFolderSelector").innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) - .join(""); - form.querySelector("#SignalRMediaFolderSelector").innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) - .join(""); - - Dashboard.processPluginConfigurationUpdateResult(result); - return config; -} - -async function syncMediaFolderSettings(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const [mediaFolderId, libraryId] = form.querySelector("#MediaFolderSelector").value.split(","); - const mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; - const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); - - config.IgnoredFolders = ignoredFolders; - form.querySelector("#IgnoredFolders").value = ignoredFolders.join(); - config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; - config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; - config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; - config.VFS_Location = form.querySelector("#VFS_Location").value; - config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; - if (mediaFolderConfig) { - for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { - c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; - c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; - } - } - else { - config.VFS_Enabled = form.querySelector("#VFS_Enabled").checked; - config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; - } - - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); - - return config; -} - -async function syncSignalrSettings(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const [mediaFolderId, libraryId] = form.querySelector("#SignalRMediaFolderSelector").value.split(","); - const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); - - config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; - config.SignalR_AutoReconnectInSeconds = reconnectIntervals; - config.SignalR_EventSources = retrieveSimpleList(form, "SignalREventSources"); - form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); - - const mediaFolderConfig = mediaFolderId && libraryId ? config.MediaFolders.find((m) => m.MediaFolderId === mediaFolderId && m.LibraryId === libraryId) : undefined; - if (mediaFolderConfig) { - for (const c of config.MediaFolders.filter(m => m.LibraryId === libraryId)) { - c.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; - c.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; - } - } - else { - config.SignalR_FileEvents = form.querySelector("#SignalRDefaultFileEvents").checked; - config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; - } - - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - Dashboard.processPluginConfigurationUpdateResult(result); - - return config; -} - -async function toggleExpertMode(value) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - - config.ExpertMode = value; - - await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config); - - Dashboard.alert(value ? Messages.ExpertModeEnabled : Messages.ExpertModeDisabled); - - return config; -} - -async function syncUserSettings(form) { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const userId = form.querySelector("#UserSelector").value; - if (!userId) - return config; - - let userConfig = config.UserList.find((c) => userId === c.UserId); - if (!userConfig) { - userConfig = { UserId: userId }; - config.UserList.push(userConfig); - } - - // The user settings goes below here; - userConfig.EnableSynchronization = form.querySelector("#UserEnableSynchronization").checked; - userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; - userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; - userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; - userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; - userConfig.SyncUserDataInitialSkipEventCount = form.querySelector("#SyncUserDataInitialSkipEventCount").checked ? 2 : 0; - userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; - userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s - userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; - - // Only try to save a new token if a token is not already present. - const username = form.querySelector("#UserUsername").value; - const password = form.querySelector("#UserPassword").value; - if (!userConfig.Token) try { - const response = await getApiKey(username, password, true); - userConfig.Username = username; - userConfig.Token = response.apikey; - } - catch (err) { - Dashboard.alert(Messages.InvalidCredentials); - console.error(err, Messages.InvalidCredentials); - userConfig.Username = ""; - userConfig.Token = ""; - } - - const result = await ApiClient.updatePluginConfiguration(PluginConfig.pluginId, config) - Dashboard.processPluginConfigurationUpdateResult(result); - - return config; -} - -export default function (page) { - const MaxDebugPresses = 7; - let expertPresses = 0; - let expertMode = false; - /** @type {HTMLFormElement} */ - const form = page.querySelector("#ShokoConfigForm"); - const serverVersion = form.querySelector("#ServerVersion"); - const userSelector = form.querySelector("#UserSelector"); - const mediaFolderSelector = form.querySelector("#MediaFolderSelector"); - const signalrMediaFolderSelector = form.querySelector("#SignalRMediaFolderSelector"); - - // Refresh the view after we changed the settings, so the view reflect the new settings. - const refreshSettings = async (config) => { - if (config.ExpertMode) { - form.classList.add("expert-mode"); - } - else { - form.classList.remove("expert-mode"); - } - if (config.ServerVersion) { - let version = `Version ${config.ServerVersion.Version}`; - const extraDetails = [ - config.ServerVersion.ReleaseChannel || "", - config.ServerVersion. - Commit ? config.ServerVersion.Commit.slice(0, 7) : "", - ].filter(s => s).join(", "); - if (extraDetails) - version += ` (${extraDetails})`; - serverVersion.value = version; - } - else { - serverVersion.value = "Version N/A"; - } - if (!config.CanCreateSymbolicLinks) { - form.querySelector("#WindowsSymLinkWarning1").removeAttribute("hidden"); - form.querySelector("#WindowsSymLinkWarning2").removeAttribute("hidden"); - } - if (config.ApiKey) { - form.querySelector("#Url").setAttribute("disabled", ""); - form.querySelector("#PublicUrl").setAttribute("disabled", ""); - form.querySelector("#Username").setAttribute("disabled", ""); - form.querySelector("#Password").value = ""; - form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); - form.querySelector("#ConnectionResetContainer").removeAttribute("hidden"); - form.querySelector("#ConnectionSection").removeAttribute("hidden"); - form.querySelector("#MetadataSection").removeAttribute("hidden"); - form.querySelector("#LibrarySection").removeAttribute("hidden"); - form.querySelector("#MediaFolderSection").removeAttribute("hidden"); - form.querySelector("#SignalRSection1").removeAttribute("hidden"); - form.querySelector("#SignalRSection2").removeAttribute("hidden"); - form.querySelector("#UserSection").removeAttribute("hidden"); - form.querySelector("#ExperimentalSection").removeAttribute("hidden"); - } - else { - form.querySelector("#Url").removeAttribute("disabled"); - form.querySelector("#PublicUrl").removeAttribute("disabled"); - form.querySelector("#Username").removeAttribute("disabled"); - form.querySelector("#ConnectionSetContainer").removeAttribute("hidden"); - form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); - form.querySelector("#ConnectionSection").removeAttribute("hidden"); - form.querySelector("#MetadataSection").setAttribute("hidden", ""); - form.querySelector("#LibrarySection").setAttribute("hidden", ""); - form.querySelector("#MediaFolderSection").setAttribute("hidden", ""); - form.querySelector("#SignalRSection1").setAttribute("hidden", ""); - form.querySelector("#SignalRSection2").setAttribute("hidden", ""); - form.querySelector("#UserSection").setAttribute("hidden", ""); - form.querySelector("#ExperimentalSection").setAttribute("hidden", ""); - } - - await loadUserConfig(form, form.querySelector("#UserSelector").value, config); - await loadMediaFolderConfig(form, form.querySelector("#MediaFolderSelector").value, config); - await loadSignalrMediaFolderConfig(form, form.querySelector("#SignalRMediaFolderSelector").value, config); - - Dashboard.hideLoadingMsg(); - }; - - /** - * - * @param {{ IsUsable: boolean; IsActive: boolean; State: "Disconnected" | "Connected" | "Connecting" | "Reconnecting" }} status - */ - const refreshSignalr = (status) => { - form.querySelector("#SignalRStatus").value = status.IsActive ? `Enabled, ${status.State}` : status.IsUsable ? "Disabled" : "Unavailable"; - if (status.IsUsable) { - form.querySelector("#SignalRConnectButton").removeAttribute("disabled"); - } - else { - form.querySelector("#SignalRConnectButton").setAttribute("disabled", ""); - } - if (status.IsActive) { - form.querySelector("#SignalRConnectContainer").setAttribute("hidden", ""); - form.querySelector("#SignalRDisconnectContainer").removeAttribute("hidden"); - } - else { - form.querySelector("#SignalRConnectContainer").removeAttribute("hidden"); - form.querySelector("#SignalRDisconnectContainer").setAttribute("hidden", ""); - } - }; - - const onError = (err) => { - console.error(err); - Dashboard.alert(`An error occurred; ${err.message}`); - Dashboard.hideLoadingMsg(); - }; - - serverVersion.addEventListener("click", async function () { - if (++expertPresses === MaxDebugPresses) { - expertPresses = 0; - expertMode = !expertMode; - const config = await toggleExpertMode(expertMode); - refreshSettings(config); - return; - } - if (expertPresses >= 3) - Dashboard.alert(Messages.ExpertModeCountdown.replace("<count>", MaxDebugPresses - expertPresses).replace("<toggle>", expertMode ? "disable" : "enable")); - }); - - userSelector.addEventListener("change", function () { - loadUserConfig(page, this.value); - }); - - mediaFolderSelector.addEventListener("change", function () { - loadMediaFolderConfig(page, this.value); - }); - - signalrMediaFolderSelector.addEventListener("change", function () { - loadSignalrMediaFolderConfig(page, this.value); - }); - - form.querySelector("#UserEnableSynchronization").addEventListener("change", function () { - const disabled = !this.checked; - form.querySelector("#SyncUserDataOnImport").disabled = disabled; - form.querySelector("#SyncUserDataAfterPlayback").disabled = disabled; - form.querySelector("#SyncUserDataUnderPlayback").disabled = disabled; - form.querySelector("#SyncUserDataUnderPlaybackLive").disabled = disabled; - form.querySelector("#SyncUserDataInitialSkipEventCount").disabled = disabled; - }); - - form.querySelector("#VFS_Location").addEventListener("change", function () { - form.querySelector("#VFS_CustomLocation").disabled = this.value !== "Custom"; - if (this.value === "Custom") { - form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); - } - else { - form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); - } - }); - - form.querySelector("#UseGroupsForShows").addEventListener("change", function () { - form.querySelector("#SeasonOrdering").disabled = !this.checked; - if (this.checked) { - form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); - } - else { - form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); - } - }); - - 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", ""); - }); - - form.querySelector("#TitleAlternateOverride").addEventListener("change", function () { - const list = form.querySelector(`#TitleAlternateList`); - this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); - }); - - form.querySelector("#DescriptionSourceOverride").addEventListener("change", function () { - const list = form.querySelector("#DescriptionSourceList"); - this.checked ? list.removeAttribute("hidden") : list.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; - form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#TagMaximumDepthContainer").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("#TagMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#TagMaximumDepthContainer").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; - form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#GenreMaximumDepthContainer").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("#GenreMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#GenreMaximumDepthContainer").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 () { - Dashboard.showLoadingMsg(); - try { - const config = await ApiClient.getPluginConfiguration(PluginConfig.pluginId); - const signalrStatus = await getSignalrStatus(); - const users = await ApiClient.getUsers(); - - expertPresses = 0; - expertMode = config.ExpertMode; - - // Connection settings - form.querySelector("#Url").value = config.Url; - form.querySelector("#PublicUrl").value = config.PublicUrl; - form.querySelector("#Username").value = config.Username; - form.querySelector("#Password").value = ""; - - // Metadata settings - 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("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - 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; - form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#TagMaximumDepthContainer").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("#TagMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#TagMaximumDepthContainer").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; - form.querySelector("#TagMaximumDepth").value = config.TagMaximumDepth.toString(); - 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; - form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#GenreMaximumDepthContainer").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("#GenreMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#GenreMaximumDepthContainer").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; - form.querySelector("#GenreMaximumDepth").value = config.GenreMaximumDepth.toString(); - - 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 - initSimpleList(form, "ThirdPartyIdProviderList", config.ThirdPartyIdProviderList.map(s => s.trim()).filter(s => s)); - - // Library settings - form.querySelector("#AutoMergeVersions").checked = config.AutoMergeVersions || false; - if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows) { - 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; - form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo; - form.querySelector("#SeparateMovies").checked = config.SeparateMovies; - form.querySelector("#FilterMovieLibraries").checked = config.FilterMovieLibraries; - form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; - form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes; - form.querySelector("#AddTrailers").checked = config.AddTrailers; - form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos; - form.querySelector("#AddCreditsAsSpecialFeatures").checked = config.AddCreditsAsSpecialFeatures; - form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata; - - // Media Folder settings - - form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); - form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; - form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; - form.querySelector("#VFS_AttachRoot").checked = config.VFS_AttachRoot; - form.querySelector("#VFS_Location").value = config.VFS_Location; - form.querySelector("#VFS_CustomLocation").value = config.VFS_CustomLocation || ""; - form.querySelector("#VFS_CustomLocation").disabled = config.VFS_Location !== "Custom"; - if (config.VFS_Location === "Custom") { - form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); - } - else { - form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); - } - form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; - form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; - mediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) - .join(""); - - // SignalR settings - form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; - form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); - initSimpleList(form, "SignalREventSources", config.SignalR_EventSources); - signalrMediaFolderSelector.innerHTML = `<option value="">Default settings for new media folders</option>` + config.MediaFolders - .filter((mediaFolder) => !mediaFolder.IsVirtualRoot) - .map((mediaFolder) => `<option value="${mediaFolder.MediaFolderId},${mediaFolder.LibraryId}">${mediaFolder.LibraryName} (${mediaFolder.MediaFolderPath})</option>`) - .join(""); - form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; - form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; - - // User settings - userSelector.innerHTML = `<option value="">Click here to select a user</option>` + users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); - - // Experimental settings - form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; - - if (!config.ApiKey) { - Dashboard.alert(Messages.ConnectToShoko); - } - - refreshSettings(config); - refreshSignalr(signalrStatus); - } - catch (err) { - Dashboard.alert(Messages.UnableToRender); - console.error(err, Messages.UnableToRender) - Dashboard.hideLoadingMsg(); - } - }); - - form.addEventListener("submit", function (event) { - event.preventDefault(); - if (!event.submitter) return; - switch (event.submitter.name) { - default: - case "all-settings": - Dashboard.showLoadingMsg(); - defaultSubmit(form).then(refreshSettings).catch(onError); - break; - case "settings": - Dashboard.showLoadingMsg(); - syncSettings(form).then(refreshSettings).catch(onError); - break; - case "establish-connection": - Dashboard.showLoadingMsg(); - defaultSubmit(form) - .then(refreshSettings) - .then(getSignalrStatus) - .then(refreshSignalr) - .catch(onError); - break; - case "reset-connection": - Dashboard.showLoadingMsg(); - resetConnectionSettings(form) - .then(refreshSettings) - .then(getSignalrStatus) - .then(refreshSignalr) - .catch(onError); - break; - case "remove-media-folder": - removeMediaFolder(form).then(refreshSettings).catch(onError); - break; - case "unlink-user": - unlinkUser(form).then(refreshSettings).catch(onError); - break; - case "media-folder-settings": - Dashboard.showLoadingMsg(); - syncMediaFolderSettings(form).then(refreshSettings).catch(onError); - break; - case "signalr-connect": - signalrConnect().then(refreshSignalr).catch(onError); - break; - case "signalr-disconnect": - signalrDisconnect().then(refreshSignalr).catch(onError); - break; - case "signalr-settings": - syncSignalrSettings(form).then(refreshSettings).catch(onError); - break; - case "user-settings": - Dashboard.showLoadingMsg(); - syncUserSettings(form).then(refreshSettings).catch(onError); - break; - } - return false; - }); -} - -/** - * Initialize a selectable list. - * - * @param {HTMLFormElement} form - * @param {string} name - * @param {string[]} enabled - * @param {string[]} order - * @returns {void} - */ -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++); - } -} - -/** - * @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; - } -} - -/** - * 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), - ]; -} - -/** - * 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/Pages/Dummy.html b/Shokofin/Pages/Dummy.html new file mode 100644 index 00000000..72e5d676 --- /dev/null +++ b/Shokofin/Pages/Dummy.html @@ -0,0 +1,7 @@ +<div data-role="page" class="page type-interior pluginConfigurationPage withTabs" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/Shoko.Utilities.Dummy.js"> + <div data-role="content"> + <div class="content-primary"> + Dummy. + </div> + </div> +</div> \ No newline at end of file diff --git a/Shokofin/Pages/Scripts/Common.js b/Shokofin/Pages/Scripts/Common.js new file mode 100644 index 00000000..56083d82 --- /dev/null +++ b/Shokofin/Pages/Scripts/Common.js @@ -0,0 +1,1132 @@ +/** + * Example page showcasing the different view events we can use and their + * details. + */ + +//#region Dashboard + +/** + * @type {DashboardPrototype} + */ +export const Dashboard = globalThis.Dashboard; + +/** + * + * @callback GenericFunction + * @returns {void} + */ + +/** + * Prototype for the dashboard. + * + * @typedef {Object} DashboardPrototype + * @property {DashboardConfirm1 | DashboardConfirm2} confirm Show a confirm dialog. + * @property {DashboardAlert} alert Alert a message. + * @property {ApiClientGetUrl} getPluginUrl The internal URL of the plugin resource. + * @property {GenericFunction} showLoadingMsg Show a loading message. + * @property {GenericFunction} hideLoadingMsg Hide a loading message. + * @property {GenericFunction} processPluginConfigurationUpdateResult Process a plugin configuration update. + * @property {DashboardNavigate} navigate Navigate to a route. + * // TODO: Add the rest here if needed. + */ + +/** + * Show a confirm dialog. + * + * @callback DashboardConfirm1 + * @param {string} message The message to show. + * @param {string} title The title of the confirm dialog. + * @returns {Promise<void>} + */ +/** + * Show a confirm dialog. + * + * @callback DashboardConfirm2 + * @param {{ + * }} options The message to show. + * @returns {Promise<void>} + */ + +/** + * Alert message options. + * + * @typedef {Object} DashboardAlertOptions + * @property {string} message The message to show. + * @property {string} [title] The title of the alert. + * @property {GenericFunction} [callback] The callback to call when the alert is closed. + */ + +/** + * Show an alert message. + * + * @callback DashboardAlert + * @param {string | DashboardAlertOptions} message The message to show, or an options object for the alert to show. + * @returns {void} + */ + +/** + * Navigate to a url. + * + * @callback DashboardNavigate + * @param {string} url - The url to navigate to. + * @param {boolean} [preserveQueryString] - A flag to indicate the current query string should be appended to the new url. + * @returns {Promise<void>} + */ + +//#endregion + +//#region API Client + +/** + * @type {ApiClientPrototype} + */ +export const ApiClient = globalThis.ApiClient; + +/** + * @typedef {Object} User + * @property {string} Id The user id. + * @property {string} Name The user name. + */ + +/** + * @callback ApiClientGetUsers + * @returns {Promise<User[]>} The users. + */ + +/** + * @typedef {Object} ApiClientPrototype + * @property {ApiClientGetPluginConfiguration} getPluginConfiguration Get a plugin configuration. + * @property {ApiClientUpdatePluginConfiguration} updatePluginConfiguration Update a plugin configuration. + * @property {ApiClientGetUsers} getUsers Get the current user. + * @property {ApiClientGetUrl} getUrl Get an API url. + * @property {ApiClientFetch} fetch Fetch an API call. + * // TODO: Add the rest here if needed. + */ + +/** + * @typedef {Object} ApiClientGetPluginConfiguration + * @property {string} id The plugin id. + * @returns {Promise<T>} The plugin configuration. + * @template T The type of the plugin configuration. + */ + +/** + * @callback ApiClientUpdatePluginConfiguration + * @param {string} id The plugin id. + * @param {T} config The plugin configuration. + * @returns {Promise<any>} Some sort of result we don't really care about. + * @template T + */ + +/** + * @callback ApiClientGetUrl + * @param {string} url The url of the API call. + * @returns {string} The modified url of the API call. + */ + +/** + * @typedef {Object} ApiClientFetchOptions + * @property {"json"} dataType The data type of the API call. + * @property {"GET" | "POST"} [type] The HTTP method of the API call. + * @property {string | FormData | Blob} [data] The data of the API call. + * @property {Record<string, string>} [headers] The headers of the API call. + * @property {string} url The url of the API call. + */ + +/** + * Fetch an API call. + * + * @callback ApiClientFetch + * @param {Object} options The options of the API call. + * @returns {Promise<T>} The result of the API call. + * @template T + */ + +//#endregion + +//#region Library Menu + +/** + * @type {LibraryMenuPrototype} + */ +export const LibraryMenu = globalThis.LibraryMenu; + +/** + * @typedef {Object} LibraryMenuPrototype + * @property {LibraryMenuSetTabs} setTabs Set the tabs. + */ + +/** + * @typedef {Object} LibraryMenuTab + * @property {string} name The display name of the tab. + * @property {string} href The url of the tab in the react router. + */ + +/** + * @callback LibraryMenuSetTabsFactory + * @returns {LibraryMenuTab[]} The tabs. + */ + +/** + * @callback LibraryMenuSetTabs + * @param {string} tabSetName The name of the tab set. + * @param {number} index The index of the tab to select. + * @param {LibraryMenuSetTabsFactory} factory The factory function to create the tabs. + * @returns {void} Void. + */ + +//#endregion + +//#region API Client + +/** + * @typedef {{ + * IsUsable: boolean; +* IsActive: boolean; +* State: "Disconnected" | "Connected" | "Connecting" | "Reconnecting"; +* }} SignalRStatus +*/ + +/** +* @typedef {"Shoko" | "AniDB" | "TMDB"} GenericProvider +*/ + +/** +* @typedef {"Shoko" | "AniDB" | "TvDB" | "TMDB"} DescriptionProvider +*/ + +/** +* @typedef {"Shoko_Default" | "AniDB_Default" | "AniDB_LibraryLanguage" | "AniDB_CountryOfOrigin" | "TMDB_Default" | "TMDB_LibraryLanguage" | "TMDB_CountryOfOrigin"} TitleProvider +*/ + +/** +* @typedef {"ContentIndicators" | "Dynamic" | "DynamicCast" | "DynamicEnding" | "Elements" | "ElementsPornographyAndSexualAbuse" | "ElementsTropesAndMotifs" | "Fetishes" | "OriginProduction" | "OriginDevelopment" | "SettingPlace" | "SettingTimePeriod" | "SettingTimeSeason" | "SourceMaterial" | "TargetAudience" | "TechnicalAspects" | "TechnicalAspectsAdaptions" | "TechnicalAspectsAwards" | "TechnicalAspectsMultiAnimeProjects" | "Themes" | "ThemesDeath" | "ThemesTales" | "Ungrouped" | "Unsorted" | "CustomTags"} TagSource +*/ + +/** +* @typedef {"Parent" | "Child" | "Abstract" | "Weightless" | "Weighted" | "GlobalSpoiler" | "LocalSpoiler"} TagIncludeFilter +*/ + +/** +* @typedef {"Weightless" | "One" | "Two" | "Three" | "Four" | "Five" | "Six"} TagWeight +*/ + +/** +* @typedef {0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10} TagDepth +*/ + +/** +* @typedef {"None" | "Movies" | "Shared"} CollectionCreationType +*/ + +/** +* @typedef {"Default" | "ReleaseDate" | "Chronological" | "ChronologicalIgnoreIndirect"} SeasonOrderType +*/ + +/** +* @typedef {"Default" | "Excluded" | "AfterSeason" | "InBetweenSeasonByAirDate" | "InBetweenSeasonByOtherData" | "InBetweenSeasonMixed"} SpecialOrderType +*/ + +/** +* @typedef {"Default" | "Cache" | "Custom"} VirtualRootLocation +*/ + +/** +* @typedef {"Auto" | "Strict" | "Lax"} LibraryFilteringMode +*/ + +/** +* @typedef {{ +* UserId: string; +* EnableSynchronization: boolean; +* SyncUserDataAfterPlayback: boolean; +* SyncUserDataUnderPlayback: boolean; +* SyncUserDataUnderPlaybackLive: boolean; +* SyncUserDataInitialSkipEventCount: number; +* SyncUserDataUnderPlaybackAtEveryXTicks: number; +* SyncUserDataUnderPlaybackLiveThreshold: number; +* SyncUserDataOnImport: boolean; +* SyncRestrictedVideos: boolean; +* Username: string; +* Token: string; +* }} UserConfig +*/ + +/** +* @typedef {{ +* LibraryId: string; +* LibraryName: string | null; +* MediaFolderId: string; +* MediaFolderPath: string; +* ImportFolderId: number; +* ImportFolderName: string | null; +* ImportFolderRelativePath: string; +* IsVirtualRoot: boolean; +* IsMapped: boolean; +* IsFileEventsEnabled: boolean; +* IsRefreshEventsEnabled: boolean; +* IsVirtualFileSystemEnabled: boolean; +* LibraryFilteringMode: LibraryFilteringMode; +* }} MediaFolderConfig +*/ + +/** +* @typedef {{ +* Version: string; +* Commit: string | null; +* ReleaseChannel: "Stable" | "Dev" | "Debug" | null; +* ReleaseDate: string | null; +* }} ServerInformation +*/ + +/** +* @typedef {{ +* CanCreateSymbolicLinks: boolean; +* Url: string; +* PublicUrl: string; +* ServerVersion: ServerInformation | null; +* Username: string; +* ApiKey: string; +* ThirdPartyIdProviderList: Except<DescriptionProvider, "Shoko">[]; +* TitleMainOverride: boolean; +* TitleMainList: TitleProvider[]; +* TitleMainOrder: TitleProvider[]; +* TitleAlternateOverride: boolean; +* TitleAlternateList: TitleProvider[]; +* TitleAlternateOrder: TitleProvider[]; +* TitleAllowAny: boolean; +* MarkSpecialsWhenGrouped: boolean; +* DescriptionSourceOverride: boolean; +* DescriptionSourceList: DescriptionProvider[]; +* DescriptionSourceOrder: DescriptionProvider[]; +* SynopsisCleanLinks: boolean; +* SynopsisCleanMiscLines: boolean; +* SynopsisRemoveSummary: boolean; +* SynopsisCleanMultiEmptyLines: boolean; +* TagOverride: boolean; +* TagSources: TagSource[]; +* TagIncludeFilters: TagIncludeFilter[]; +* TagMinimumWeight: TagWeight; +* TagMaximumDepth: TagDepth; +* GenreOverride: boolean; +* GenreSources: TagSource[]; +* GenreIncludeFilters: TagIncludeFilter[]; +* GenreMinimumWeight: TagWeight; +* GenreMaximumDepth: TagDepth; +* HideUnverifiedTags: boolean; +* ContentRatingOverride: boolean; +* ContentRatingList: GenericProvider[]; +* ContentRatingOrder: GenericProvider[]; +* ProductionLocationOverride: boolean; +* ProductionLocationList: GenericProvider[]; +* ProductionLocationOrder: GenericProvider[]; +* UserList: UserConfig[]; +* AutoMergeVersions: boolean; +* UseGroupsForShows: boolean; +* SeparateMovies: boolean; +* FilterMovieLibraries: boolean; +* MovieSpecialsAsExtraFeaturettes: boolean; +* AddTrailers: boolean; +* AddCreditsAsThemeVideos: boolean; +* AddCreditsAsSpecialFeatures: boolean; +* CollectionGrouping: CollectionCreationType; +* SeasonOrdering: SeasonOrderType; +* SpecialsPlacement: SpecialOrderType; +* AddMissingMetadata: boolean; +* IgnoredFolders: string[]; +* VFS_Enabled: boolean; +* VFS_Threads: number; +* VFS_AddReleaseGroup: boolean; +* VFS_AddResolution: boolean; +* VFS_AttachRoot: boolean; +* VFS_Location: VirtualRootLocation; +* VFS_CustomLocation: string; +* LibraryFilteringMode: LibraryFilteringMode; +* MediaFolders: MediaFolderConfig[]; +* SignalR_AutoConnectEnabled: boolean; +* SignalR_AutoReconnectInSeconds: number[]; +* SignalR_RefreshEnabled: boolean; +* SignalR_FileEvents: boolean; +* SignalR_EventSources: GenericProvider[]; +* Misc_ShowInMenu: boolean; +* EXPERIMENTAL_MergeSeasons: boolean; +* ExpertMode: boolean; +* }} PluginConfiguration +*/ + +/** +* Shoko API client. +*/ +export const ShokoApiClient = { + /** + * The plugin ID. + * + * @private + */ + pluginId: "5216ccbf-d24a-4eb3-8a7e-7da4230b7052", + + /** + * Get the plugin configuration. + * + * @public + * @returns {Promise<PluginConfiguration>} The plugin configuration. + */ + getConfiguration() { + return ApiClient.getPluginConfiguration(ShokoApiClient.pluginId); + }, + + /** + * Update the plugin configuration. + * + * @public + * @param {PluginConfiguration} config - The plugin configuration to update. + * @returns {Promise<any>} Some sort of result we don't really care about. + */ + updateConfiguration(config) { + return ApiClient.updatePluginConfiguration(ShokoApiClient.pluginId, config); + }, + + /** + * Get an API key for the username and password combo. Optionally get an + * user key instead of a plugin key. + * + * @public + * @param {string} username - The username. + * @param {string} password - The password. + * @param {boolean?} userKey - Optional. Whether to get a user key or a plugin key. + * @returns {Promise<{ apikey: string; }>} The API key. + */ + getApiKey(username, password, userKey = false) { + return ApiClient.fetch({ + dataType: "json", + data: JSON.stringify({ + username, + password, + userKey, + }), + headers: { + "Content-Type": "application/json", + "Accept": "application/json", + }, + type: "POST", + url: ApiClient.getUrl("Plugin/Shokofin/Host/GetApiKey"), + }); + }, + + /** + * Check the status of the SignalR connection. + * + * @private + * @returns {Promise<SignalRStatus>} The SignalR status. + */ + getSignalrStatus() { + return ApiClient.fetch({ + dataType: "json", + type: "GET", + url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Status"), + }); + }, + + /** + * Connects to the SignalR stream on the server. + * + * @public + * @returns {Promise<SignalRStatus>} The SignalR status. + */ + async signalrConnect() { + await ApiClient.fetch({ + type: "POST", + url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Connect"), + }); + return ShokoApiClient.getSignalrStatus(); + }, + + /** + * Disconnects from the SignalR stream on the server. + * + * @public + * @returns {Promise<SignalRStatus>} The SignalR status. + */ + async signalrDisconnect() { + await ApiClient.fetch({ + type: "POST", + url: ApiClient.getUrl("Plugin/Shokofin/SignalR/Disconnect"), + }); + return ShokoApiClient.getSignalrStatus(); + }, +}; + +//#endregion + +//#region State + +/** + * @type {{ +* config: PluginConfiguration | null; +* currentTab: TabType; +* expertPresses: number; +* expertMode: boolean; +* connected: boolean; +* timeout: number | null; +* }} +*/ +export const State = window["SHOKO_STATE_OBJECT"] || (window["SHOKO_STATE_OBJECT"] = { + config: null, + currentTab: "connection", + expertPresses: 0, + expertMode: false, + connected: false, + timeout: null, +}); + +//#endregion + +//#region Tabs + +/** + * @typedef {"connection" | "metadata" | "library" | "vfs" | "users" | "signalr" | "misc" | "utilities"} TabType + */ + +/** + * @typedef {Object} ShokoTab + * @property {TabType} id The tab id. + * @property {string} href The tab href. + * @property {string} helpHref The tab help href. + * @property {string} name The tab name. + * @property {boolean?} connected Optional. Whether the tab is only rendered when or when not connected. + * @property {boolean?} expertMode Optional. Whether the tab is only rendered when in or not in expert mode. + */ + +const DefaultHelpLink = "https://docs.shokoanime.com/shokofin/configuration/"; + +/** + * @type {readonly ShokoTab[]} + */ +const Tabs = [ + { + id: "connection", + href: getConfigurationPageUrl("Shoko.Settings", "connection"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#connection", + name: "Connection", + }, + { + id: "metadata", + href: getConfigurationPageUrl("Shoko.Settings", "metadata"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#metadata", + name: "Metadata", + connected: true, + }, + { + id: "library", + href: getConfigurationPageUrl("Shoko.Settings", "library"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#library", + name: "Library", + connected: true, + }, + { + id: "vfs", + href: getConfigurationPageUrl("Shoko.Settings", "vfs"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#vfs", + name: "VFS", + connected: true, + }, + { + id: "users", + href: getConfigurationPageUrl("Shoko.Settings", "users"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#users", + name: "Users", + connected: true, + }, + { + id: "signalr", + href: getConfigurationPageUrl("Shoko.Settings", "signalr"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#signalr", + name: "SignalR", + connected: true, + }, + { + id: "misc", + href: getConfigurationPageUrl("Shoko.Settings", "misc"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#misc", + name: "Misc", + connected: true, + expertMode: true, + }, + { + id: "utilities", + href: getConfigurationPageUrl("Shoko.Settings", "utilities"), + helpHref: "https://docs.shokoanime.com/shokofin/configuration/#utilities", + name: "Utilities", + }, +]; + +/** + * Responsible for updating the tabs at the top of the page. + * + * @param {HTMLElement} view - The view element. + * @param {TabType} [tabName] - Optional. Change the current tab. + */ +export function updateTabs(view, tabName) { + if (tabName) { + State.currentTab = tabName; + } + const tabs = Tabs.filter(tab => tab.id === State.currentTab || (tab.connected === undefined || tab.connected === State.connected) && (tab.expertMode === undefined || tab.expertMode === State.expertMode)); + let index = tabs.findIndex((tab => tab.id === State.currentTab)); + if (index === -1) + index = 0; + + LibraryMenu.setTabs("shoko", index, () => tabs); + + const helpLink = view.querySelector(".sectionTitleContainer > a.helpLink"); + if (helpLink) { + const currentTab = Tabs.find(tab => tab.id === State.currentTab); + if (currentTab) { + helpLink.setAttribute("href", currentTab.helpHref); + } + else { + helpLink.setAttribute("href", DefaultHelpLink); + } + } +} + +//#endregion + +//#region Constants + + +const Messages = { + UnableToRender: "There was an error loading the page, please refresh once to see if that will fix it, and if it doesn't, then reach out to support or debug it yourself. Your call.", +}; + + +//#endregion + +//#region Event Lifecycle + +/** + * Possible properties. + * + * @typedef {"fullscreen"} Property + */ + +/** + * View extra options. + * + * @typedef {Object} ViewExtraOptions + * @property {boolean} supportsThemeMedia Supports theme media. + * @property {boolean} enableMediaControls Enables media controls. + */ + +/** + * Minimal event details. + * + * @typedef {Object} MinimalDetails + * @property {string} type The request route type. + * @property {Property[]} properties The properties that are available in the event. + */ + +/** + * Full event details. + * + * @typedef {Object} FullDetails + * @property {string?} type The request route type. + * @property {Property[]} properties The properties that are available in the event. + * @property {Record<string, string>} params The search query parameters of the current view, from the React's router's POV. + * @property {boolean} [isRestored] Whether the current view is restored from a previous hidden state or a brand new view. + * @property {any?} state The state of the current view, from the React's router's POV. + * @property {ViewExtraOptions} options - The options of the current view. + */ + +/** + * First event that's triggered when the page is initialized. + * + * @callback onViewInit + * @this {HTMLDivElement} - The view element. + * @param {CustomEvent<{}>} event - The event with the minimal details. + * @returns {void} Void. + */ + +/** + * Triggered after the init event and when the page is restored from a previous + * hidden state, but right before the view is shown. + * + * @callback onViewBeforeShow + * @this {HTMLDivElement} - The view element. + * @param {CustomEvent<FullDetails>} event - The event with the full details. + * @returns {void} Void. + */ +/** + * Triggered after the init event and when the page is restored from a previous + * hidden state, when the view is shown. + * + * @callback onViewShow + * @this {HTMLDivElement} - The view element. + * @param {CustomEvent<FullDetails>} event - The event with the full details. + * @returns {void} Void. + */ + +/** + * Triggered right before the view is hidden. Can be used to cancel the + * hiding process by calling {@link Event.preventDefault event.preventDefault()}. + * + * @callback onViewBeforeHide + * @this {HTMLDivElement} - The view element. + * @param {CustomEvent<MinimalDetails>} event - The event with the minimal details. + * @returns {void} Void. + */ + +/** + * Triggered right after the view is hidden. Can be used for clearing up state + * before the view is shown again or before it's destroyed. + * + * @callback onViewHide + * @this {HTMLDivElement} - The view element. + * @param {CustomEvent<MinimalDetails>} event - The event with the minimal details. + * @returns {void} Void. + */ + +/** + * Triggered right before the view is destroyed. This means the view will not + * be shown again. If you navigate to and from the page it will instead + * re-initialise a new instance of the view if it has already destroyed the + * previous instance by the time it should show the view. + * + * @callback onViewDestroy + * @this {HTMLDivElement} - The view element. + * @param {CustomEvent<{}>} event - The event with the no details. + * @returns {void} Void. + */ + +/** + * View lifecycle events in all their glory. + * + * @typedef {Object} ViewLifecycleEvents + * @property {onViewInit} onInit + * + * First event that's triggered when the page is initialized. + * + * @property {onViewBeforeShow} onBeforeShow + * + * Triggered after the init event and when the page is restored from a previous + * hidden state, but right before the view is shown. + * + * @property {onViewShow} onShow + * + * Triggered after the init event and when the page is restored from a previous + * hidden state, when the view is shown. + * + * @property {onViewBeforeHide} onBeforeHide + * + * Triggered right before the view is hidden. Can be used to cancel the + * hiding process by calling {@link Event.preventDefault event.preventDefault()}. + * + * @property {onViewHide} onHide + * + * Triggered right after the view is hidden. Can be used for clearing up state + * before the view is shown again or before it's destroyed. + * + * @property {onViewDestroy} onDestroy + * + * Triggered right before the view is destroyed. This means the view will not + * be shown again. If you navigate to and from the page it will instead + * re-initialise a new instance of the view if it has already destroyed the + * previous instance by the time it should show the view. + */ + +/** + * @param {HTMLDivElement} view - The view element. + * @param {ViewLifecycleEvents} events - The events. + * @param {TabType} [initialTab] - The initial tab. + * @returns {void} Void. + */ +export function setupEvents(view, events, initialTab = "connection") { + if (events.onBeforeShow) { + view.addEventListener("viewbeforeshow", events.onBeforeShow.bind(view)); + } + + if (events.onShow) { + view.addEventListener("viewshow", async (event) => { + try { + // Clear the current timeout if there is one. + if (State.timeout) { + clearTimeout(State.timeout); + State.timeout = null; + } + + // Set the current tab if the current view supports tabs. + if (view.classList.contains("withTabs")) { + State.currentTab = new URLSearchParams(window.location.href.split("#").slice(1).join("#").split("?").slice(1).join("?")).get("tab") || initialTab; + + // And update the tabs if the state is already initialised. + if (State.config) { + updateTabs(view); + } + } + + // Initialise the state now if it's not yet initialised. + if (!State.config) { + Dashboard.showLoadingMsg(); + State.config = await ShokoApiClient.getConfiguration(); + State.expertPresses = 0; + State.expertMode = State.config.ExpertMode; + State.connected = Boolean(State.config.ApiKey); + } + + // Show the view. + await events.onShow.call(view, event); + + if (view.classList.contains("withTabs")) { + updateTabs(view); + } + } + catch (err) { + // Show an error message if we failed to render the view. + Dashboard.alert(Messages.UnableToRender); + console.error(Messages.UnableToRender, err); + } + finally { + // Hide the loading message if there is one. + Dashboard.hideLoadingMsg(); + } + }); + } + + if (events.onBeforeHide) { + view.addEventListener("viewbeforehide", events.onBeforeHide.bind(view)); + } + + if (events.onHide) { + view.addEventListener("viewhide", (event) => { + // Clear the current timeout if there is one. + if (State.timeout) { + clearTimeout(State.timeout); + State.timeout = null; + } + + // Hide the view. + events.onHide.call(view, event); + + // Reset the state after the view is hidden if we're not switching + // to another view. + State.timeout = setTimeout(() => { + State.config = null; + State.currentTab = initialTab; + State.expertPresses = 0; + State.expertMode = false; + State.connected = false; + State.timeout = null; + }, 100); + }); + } + + if (events.onDestroy) { + view.addEventListener("viewdestroy", events.onDestroy.bind(view)); + } + + // Override any links with link redirection set. + view.querySelectorAll("a.link-redirection").forEach(overrideLink); + + view.querySelectorAll("div[is=\"sortable-checkbox-list\"]").forEach(overrideSortableCheckboxList); + + // The view event is only send if a controller factory is not provided… + // which is not the case here, since we're running in the controller factory + // right now. So just send the init event now. + if (events.onInit) { + const initEvent = new CustomEvent("viewinit", { detail: {}, bubbles: true, cancelable: false }); + + events.onInit.call(view, initEvent); + } +} + +//#endregion + +//#region Controller Factory + +/** + * A factory responsible for creating a new view and setting up its events as + * needed. + * + * @callback controllerFactoryFn + * @param {HTMLDivElement} view - The view element. + * @returns {void} Void. + */ + +/** + * Controller factory options. + * + * @typedef {Object} controllerFactoryOptions + * @property {ViewLifecycleEvents} events The lifecycle events for the view. + * @property {TabType} [initialTab] - The initial tab. + */ + +/** + * Create a new view and set up its events as needed. + * + * @param {controllerFactoryOptions} options - The controller factory options. + * @returns {controllerFactoryFn} The controller factory. + */ +export function createControllerFactory(options) { + const { events, initialTab } = options; + return function(view) { + setupEvents(view, events, initialTab); + } +} + +//#endregion + +//#region Helpers + +//#region Helpers - Handle Error + +/** + * Handle an error during a configuration update. + * + * @param {any} err - The error. + */ +export function handleError(err) { + console.error(err); + Dashboard.alert(`An error occurred; ${err.message}`); + Dashboard.hideLoadingMsg(); +} + +//#endregion + +//#region Helpers - Override Link + +/** + * Construct the URL for a tab on the configuration page. + * + * @param {string} page + * @param {string} [tab] + * @returns {string} + */ +function getConfigurationPageUrl(page, tab = "") { + const urlSearch = new URLSearchParams(); + urlSearch.set("name", page); + if (tab) { + urlSearch.set("tab", tab); + } + return "configurationpage?" + urlSearch.toString(); +} + +/** + * Redirect a link to the configuration page through React instead of the + * browser. + * + * @param {HTMLAnchorElement} event + */ +function onLinkRedirectClick(event) { + event.preventDefault(); + Dashboard.navigate(getConfigurationPageUrl(event.target.dataset.href)); +} + +/** + * Override links to the configuration page in the DOM. + * + * @param {HTMLAnchorElement} target - The link to override. + * @returns {void} Void. + */ +function overrideLink(target) { + const href = target.getAttribute("href"); + target.dataset.href = href; + target.href = location.href.split("#")[0] + "#" + getConfigurationPageUrl(href); + target.addEventListener("click", onLinkRedirectClick); +} + +//#endregion + +//#region Helpers - Readonly List + +/** + * Initialize a readonly list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} entries + * @returns {void} + */ +export function renderReadonlyList(form, name, entries) { + const list = form.querySelector(`#${name} .checkboxList`); + const listItems = entries.map((entry) => + `<div class="listItem"><div class="listItemBody"><h3 class="listItemBodyText">${entry}</h3></div></div>` + ); + list.innerHTML = listItems.join(""); +} + +//#endregion + +//#region Helpers - Checkbox List + +/** + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @returns {void} + **/ +export function renderCheckboxList(form, name, enabled) { + for (const item of Array.from(form.querySelectorAll(`#${name}[is=\"checkbox-list\"] .listItem input[data-option]`))) { + if (enabled.includes(item.dataset.option)) + 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[]} + **/ +export function retrieveCheckboxList(form, name) { + return Array.from(form.querySelectorAll(`#${name}[is=\"checkbox-list\"] .listItem input[data-option]`)) + .filter(item => item.checked) + .map(item => item.dataset.option) + .sort(); +} + +//#endregion + +//#region Helpers - Sortable Checkbox List + +/** + * Handle the click event on the buttons within a sortable list. + * + * @param {PointerEvent} event - The click event. + **/ +function onSortableContainerClick(event) { + const btnSortable = getParentWithClass(event.target, "btnSortable"); + if (!btnSortable) return; + + const listItem = getParentWithClass(btnSortable, "sortableOption"); + if (!listItem) return; + + const list = getParentWithClass(listItem, "paperList"); + if (!list) return; + + if (btnSortable.classList.contains("btnSortableMoveDown")) { + const next = listItem.nextElementSibling; + if (next) { + listItem.parentElement.removeChild(listItem); + next.parentElement.insertBefore(listItem, next.nextSibling); + } + } + else { + const prev = listItem.previousElementSibling; + if (prev) { + listItem.parentElement.removeChild(listItem); + prev.parentElement.insertBefore(listItem, prev); + } + } + + let index = 0; + for (const option of list.querySelectorAll(".sortableOption")) { + adjustSortableListElement(option, index++); + } +} + +/** + * Override the click event on the buttons within a sortable list. + * + * @param {HTMLDivElement} element + */ +function overrideSortableCheckboxList(element) { + element.addEventListener("click", onSortableContainerClick); +} + +/** + * Adjust the sortable list element. + * + * @param {HTMLElement} element - The element. + * @param {number} index - The index of the element. + */ +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 { + button.title = "Down"; + button.classList.add("btnSortableMoveDown"); + button.classList.remove("btnSortableMoveUp"); + icon.classList.add("keyboard_arrow_down"); + icon.classList.remove("keyboard_arrow_up"); + } +} + +/** + * Get the parent element with the given class, or null if not found. + * + * @param {HTMLElement} element - The element. + * @param {string} className - The class name. + * @returns {HTMLElement | null} The parent element with the given class, or + * null if not found. + */ +function getParentWithClass(element, className) { + return element.parentElement.classList.contains(className) ? element.parentElement : null; +} + +/** + * Render a sortable checkbox list. + * + * @param {HTMLFormElement} form + * @param {string} name + * @param {string[]} enabled + * @param {string[]} order + * @returns {void} + */ +export function renderSortableCheckboxList(form, name, enabled, order) { + let index = 0; + const list = form.querySelector(`#${name}[is=\"sortable-checkbox-list\"] .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++); + } +} + +/** + * Retrieve the enabled state and order list from a sortable list. + * + * @param {HTMLElement} view - The view element. + * @param {string} name - The name of the sortable checkbox list to retrieve. + * @returns {[string[], string[]]} + */ +export function retrieveSortableCheckboxList(view, name) { + const titleElements = Array.from(view.querySelectorAll(`#${name}[is=\"sortable-checkbox-list\"] .listItem input[data-option]`)); + const getValue = (el) => el.dataset.option; + return [ + titleElements + .filter((el) => el.checked) + .map(getValue) + .sort(), + titleElements + .map(getValue), + ]; +} + +//#endregion + +//#endregion \ No newline at end of file diff --git a/Shokofin/Pages/Scripts/Dummy.js b/Shokofin/Pages/Scripts/Dummy.js new file mode 100644 index 00000000..53daf80a --- /dev/null +++ b/Shokofin/Pages/Scripts/Dummy.js @@ -0,0 +1,38 @@ +/** + * @type {import("./Common.js").ApiClientPrototype} + */ +const ApiClient = globalThis.ApiClient; + +/** + * @type {import("./Common.js").DashboardPrototype} + */ +const Dashboard = globalThis.Dashboard; + +/** + * @type {import("./Common.js")} + */ +const { State, createControllerFactory } = await import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); + +export default createControllerFactory({ + initialTab: "utilities", + events: { + onShow(event) { + const content = this.querySelector(".content-primary"); + const { isRestored = false } = event.detail; + if (isRestored) { + State.timeout = setTimeout(() => { + content.innerHTML = "Baka baka!"; + }, 2000); + } + else { + State.timeout = setTimeout(() => { + content.innerHTML = "Baka!"; + }, 2000); + } + }, + onHide() { + const content = this.querySelector(".content-primary"); + content.innerHTML = "Dummy."; + }, + }, +}); diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js new file mode 100644 index 00000000..31f603de --- /dev/null +++ b/Shokofin/Pages/Scripts/Settings.js @@ -0,0 +1,1222 @@ +/** + * @type {import("./Common.js").ApiClientPrototype} + */ +const ApiClient = globalThis.ApiClient; + +/** + * @type {import("./Common.js").DashboardPrototype} + */ +const Dashboard = globalThis.Dashboard; + +/** + * @type {import("./Common.js")} + */ +const { + ShokoApiClient, + State, + createControllerFactory, + handleError, + renderCheckboxList, + renderReadonlyList, + renderSortableCheckboxList, + retrieveCheckboxList, + retrieveSortableCheckboxList, + updateTabs, +} = await import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); + +//#region Constants + +/** + * @typedef {"Connection" | "Metadata_Title" | "Metadata_Description" | "Metadata_TagGenre" | "Metadata_Misc" | "Metadata_ThirdPartyIntegration" | "Library_Basic" | "Library_Collection" | "Library_New" | "Library_Existing" | "Library_Experimental" | "VFS_Basic" | "VFS_Location" | "User" | "SignalR_Connection" | "SignalR_Basic" | "SignalR_Library_New" | "SignalR_Library_Existing" | "Misc" | "Utilities"} SectionType + */ + +const MaxDebugPresses = 7; + +/** + * @type {SectionType[]} + */ +const Sections = [ + "Connection", + "Metadata_Title", + "Metadata_Description", + "Metadata_TagGenre", + "Metadata_Misc", + "Metadata_ThirdPartyIntegration", + "Library_Basic", + "Library_Collection", + "Library_New", + "Library_Existing", + "Library_Experimental", + "VFS_Basic", + "VFS_Location", + "User", + "SignalR_Connection", + "SignalR_Basic", + "SignalR_Library_New", + "SignalR_Library_Existing", + "Misc", + "Utilities", +]; + +const Messages = { + ExpertModeCountdown: "Press <count> more times to <toggle> expert mode.", + ExpertModeEnabled: "Expert mode enabled.", + ExpertModeDisabled: "Expert mode disabled.", + ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", + ConnectedToShoko: "Connection established.", + DisconnectedToShoko: "Connection has been reset.", + InvalidCredentials: "An error occurred while trying to authenticating the user using the provided credentials.", +}; + +//#endregion + +//#region Controller Logic + +export default createControllerFactory({ + events: { + onInit() { + const view = this; + const form = view.querySelector("form"); + + form.querySelector("#ServerVersion").addEventListener("click", async function () { + if (++State.expertPresses === MaxDebugPresses) { + State.expertPresses = 0; + State.expertMode = !State.expertMode; + const config = await toggleExpertMode(State.expertMode); + await updateView(view, form, config); + return; + } + if (State.expertPresses >= 3) + Dashboard.alert(Messages.ExpertModeCountdown.replace("<count>", MaxDebugPresses - State.expertPresses).replace("<toggle>", State.expertMode ? "disable" : "enable")); + }); + + form.querySelector("#UserSelector").addEventListener("change", function () { + applyUserConfigToForm(form, this.value); + }); + + form.querySelector("#MediaFolderSelector").addEventListener("change", function () { + applyLibraryConfigToForm(form, this.value); + }); + + form.querySelector("#SignalRMediaFolderSelector").addEventListener("change", function () { + applySignalrLibraryConfigToForm(form, this.value); + }); + + form.querySelector("#UserEnableSynchronization").addEventListener("change", function () { + const disabled = !this.checked; + form.querySelector("#SyncUserDataOnImport").disabled = disabled; + form.querySelector("#SyncUserDataAfterPlayback").disabled = disabled; + form.querySelector("#SyncUserDataUnderPlayback").disabled = disabled; + form.querySelector("#SyncUserDataUnderPlaybackLive").disabled = disabled; + form.querySelector("#SyncUserDataInitialSkipEventCount").disabled = disabled; + }); + + form.querySelector("#VFS_Location").addEventListener("change", function () { + form.querySelector("#VFS_CustomLocation").disabled = this.value !== "Custom"; + if (this.value === "Custom") { + form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); + } + else { + form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); + } + }); + + form.querySelector("#UseGroupsForShows").addEventListener("change", function () { + form.querySelector("#SeasonOrdering").disabled = !this.checked; + if (this.checked) { + form.querySelector("#SeasonOrderingContainer").removeAttribute("hidden"); + } + else { + form.querySelector("#SeasonOrderingContainer").setAttribute("hidden", ""); + } + }); + + form.querySelector("#TitleMainOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleMainList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#TitleAlternateOverride").addEventListener("change", function () { + const list = form.querySelector(`#TitleAlternateList`); + this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); + }); + + form.querySelector("#DescriptionSourceOverride").addEventListener("change", function () { + const list = form.querySelector("#DescriptionSourceList"); + this.checked ? list.removeAttribute("hidden") : list.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; + form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#TagMaximumDepthContainer").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("#TagMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#TagMaximumDepthContainer").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; + form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#GenreMaximumDepthContainer").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("#GenreMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMaximumDepthContainer").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", ""); + }); + + form.addEventListener("submit", function (event) { + event.preventDefault(); + if (!event.submitter) return; + switch (event.submitter.name) { + case "settings": + Dashboard.showLoadingMsg(); + syncSettings(form) + .then((config) => updateView(view, form, config)) + .catch(handleError); + break; + case "remove-library": + removeLibraryConfig(form) + .then((config) => updateView(view, form, config)) + .catch(handleError); + break; + case "unlink-user": + removeUserConfig(form) + .then((config) => updateView(view, form, config)) + .catch(handleError); + break; + case "signalr-connect": + ShokoApiClient.signalrConnect() + .then((status) => updateSignalrStatus(form, status)) + .catch(handleError); + break; + case "signalr-disconnect": + ShokoApiClient.signalrDisconnect() + .then((status) => updateSignalrStatus(form, status)) + .catch(handleError); + break; + case "reset-connection": + Dashboard.showLoadingMsg(); + resetConnection(form) + .then((config) => updateView(view, form, config)) + .catch(handleError); + break; + default: + case "establish-connection": + Dashboard.showLoadingMsg(); + defaultSubmit(form) + .then((config) => updateView(view, form, config)) + .catch(handleError); + break; + } + return false; + }); + }, + + async onShow() { + const view = this; + const form = view.querySelector("form"); + + // Apply the configuration to the form. + await applyConfigToForm(form, State.config); + + // Update the view. + await updateView(view, form, State.config); + + // Show the alert if we're not connected. + if (!State.connected) { + Dashboard.alert(Messages.ConnectToShoko); + } + }, + + onHide() { + const form = this.querySelector("form"); + applyFormToConfig(form, State.config); + }, + } +}); + +/** + * Update the view to reflect the current state. + * + * @param {HTMLDivElement} view - The view element. + * @param {HTMLFormElement} form - The form element. + * @param {PluginConfiguration} config - The plugin configuration. + * @returns {Promise<void>} + */ +async function updateView(view, form, config) { + State.config = config; + State.expertPresses = 0; + State.expertMode = config.ExpertMode; + State.connected = Boolean(config.ApiKey); + + if (State.expertMode) { + form.classList.add("expert-mode"); + } + else { + form.classList.remove("expert-mode"); + } + + if (!config.CanCreateSymbolicLinks) { + form.querySelector("#WindowsSymLinkWarning1").removeAttribute("hidden"); + form.querySelector("#WindowsSymLinkWarning2").removeAttribute("hidden"); + } + + if (State.connected) { + form.querySelector("#Url").removeAttribute("required"); + form.querySelector("#Username").removeAttribute("required"); + } + else { + form.querySelector("#Url").setAttribute("required", ""); + form.querySelector("#Username").setAttribute("required", ""); + } + + /** + * @type {SectionType[]} + */ + const activeSections = []; + switch (State.currentTab) { + case "connection": + activeSections.push("Connection"); + + if (config.ServerVersion) { + let version = `Version ${config.ServerVersion.Version}`; + const extraDetails = [ + config.ServerVersion.ReleaseChannel || "", + config.ServerVersion.Commit ? config.ServerVersion.Commit.slice(0, 7) : "", + ].filter(s => s).join(", "); + if (extraDetails) + version += ` (${extraDetails})`; + form.querySelector("#ServerVersion").value = version; + } + else { + form.querySelector("#ServerVersion").value = "Version N/A"; + } + + if (State.connected) { + form.querySelector("#Url").removeAttribute("required"); + form.querySelector("#Username").removeAttribute("required"); + form.querySelector("#Url").setAttribute("disabled", ""); + form.querySelector("#PublicUrl").setAttribute("disabled", ""); + form.querySelector("#Username").setAttribute("disabled", ""); + form.querySelector("#Password").value = ""; + form.querySelector("#ConnectionSetContainer").setAttribute("hidden", ""); + form.querySelector("#ConnectionResetContainer").removeAttribute("hidden"); + } + else { + form.querySelector("#Url").setAttribute("required", ""); + form.querySelector("#Username").setAttribute("required", ""); + form.querySelector("#Url").removeAttribute("disabled"); + form.querySelector("#PublicUrl").removeAttribute("disabled"); + form.querySelector("#Username").removeAttribute("disabled"); + form.querySelector("#ConnectionSetContainer").removeAttribute("hidden"); + form.querySelector("#ConnectionResetContainer").setAttribute("hidden", ""); + } + break; + + case "metadata": + activeSections.push("Metadata_Title", "Metadata_Description", "Metadata_TagGenre", "Metadata_Misc", "Metadata_ThirdPartyIntegration"); + break; + + case "library": + activeSections.push("Library_Basic", "Library_Collection", "Library_New", "Library_Existing", "Library_Experimental"); + break; + + case "vfs": + activeSections.push("VFS_Basic", "VFS_Location"); + + await applyLibraryConfigToForm(form, form.querySelector("#MediaFolderSelector").value, config); + break; + + case "users": + activeSections.push("User"); + + await applyUserConfigToForm(form, form.querySelector("#UserSelector").value, config); + break; + + case "signalr": + activeSections.push("SignalR_Connection", "SignalR_Basic", "SignalR_Library_New", "SignalR_Library_Existing"); + + await applySignalrLibraryConfigToForm(form, form.querySelector("#SignalRMediaFolderSelector").value, config); + break; + + case "misc": + activeSections.push("Misc"); + break; + + case "utilities": + activeSections.push("Utilities"); + break; + } + + for (const sectionName of Sections) { + const id = `#${sectionName}_Section`; + const active = activeSections.includes(sectionName); + if (active) { + form.querySelector(id).removeAttribute("hidden"); + } + else { + form.querySelector(id).setAttribute("hidden", ""); + } + } + + updateTabs(view); +} + +/** + * Update the SignalR status. + * + * @param {HTMLFormElement} form - The form element. + * @param {SignalRStatus} status - The SignalR status. + */ +function updateSignalrStatus(form, status) { + form.querySelector("#SignalRStatus").value = status.IsActive ? `Enabled, ${status.State}` : status.IsUsable ? "Disabled" : "Unavailable"; + if (status.IsUsable) { + form.querySelector("#SignalRConnectButton").removeAttribute("disabled"); + } + else { + form.querySelector("#SignalRConnectButton").setAttribute("disabled", ""); + } + if (status.IsActive) { + form.querySelector("#SignalRConnectContainer").setAttribute("hidden", ""); + form.querySelector("#SignalRDisconnectContainer").removeAttribute("hidden"); + } + else { + form.querySelector("#SignalRConnectContainer").removeAttribute("hidden"); + form.querySelector("#SignalRDisconnectContainer").setAttribute("hidden", ""); + } +} + +//#endregion + +//#region Form → Configuration + +/** + * Apply a form to a configuration object. + * + * @param {HTMLFormElement} form - The form element. + * @param {PluginConfiguration} config - The plugin configuration. + */ +function applyFormToConfig(form, config) { + switch (State.currentTab) { + case "metadata": { + config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; + ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableCheckboxList(form, "TitleMainList")); + config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; + ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableCheckboxList(form, "TitleAlternateList")); + config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; + config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; + config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; + ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableCheckboxList(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 = retrieveCheckboxList(form, "TagSources").join(", "); + config.TagIncludeFilters = retrieveCheckboxList(form, "TagIncludeFilters").join(", "); + config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; + config.TagMaximumDepth = parseInt(form.querySelector("#TagMaximumDepth").value, 10); + config.GenreOverride = form.querySelector("#GenreOverride").checked; + config.GenreSources = retrieveCheckboxList(form, "GenreSources").join(", "); + config.GenreIncludeFilters = retrieveCheckboxList(form, "GenreIncludeFilters").join(", "); + config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value; + config.GenreMaximumDepth = parseInt(form.querySelector("#GenreMaximumDepth").value, 10); + config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; + ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableCheckboxList(form, "ContentRatingList")); + config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; + ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableCheckboxList(form, "ProductionLocationList")); + config.ThirdPartyIdProviderList = retrieveCheckboxList(form, "ThirdPartyIdProviderList"); + break; + } + + case "library": { + const libraryId = form.querySelector("#MediaFolderSelector").value.split(","); + const mediaFolders = libraryId ? config.MediaFolders.filter((m) => m.LibraryId === libraryId) : undefined; + + config.AutoMergeVersions = form.querySelector("#AutoMergeVersions").checked; + config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; + config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; + config.SeparateMovies = form.querySelector("#SeparateMovies").checked; + config.FilterMovieLibraries = form.querySelector("#FilterMovieLibraries").checked; + config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; + config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; + config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; + + config.CollectionGrouping = form.querySelector("#CollectionGrouping").value; + config.CollectionMinSizeOfTwo = form.querySelector("#CollectionMinSizeOfTwo").checked; + + config.VFS_Enabled = form.querySelector("#VFS_Enabled").checked; + config.LibraryFilteringMode = form.querySelector("#LibraryFilteringMode").value; + if (mediaFolders) { + for (const c of mediaFolders) { + c.IsVirtualFileSystemEnabled = form.querySelector("#MediaFolderVirtualFileSystem").checked; + c.LibraryFilteringMode = form.querySelector("#MediaFolderLibraryFilteringMode").value; + } + } + break; + } + + case "vfs": { + config.AddTrailers = form.querySelector("#AddTrailers").checked; + config.AddCreditsAsThemeVideos = form.querySelector("#AddCreditsAsThemeVideos").checked; + config.AddCreditsAsSpecialFeatures = form.querySelector("#AddCreditsAsSpecialFeatures").checked; + config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; + config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; + + config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; + config.VFS_Location = form.querySelector("#VFS_Location").value; + config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; + break; + } + + case "users": { + const userId = form.querySelector("#UserSelector").value; + if (userId) { + let userConfig = config.UserList.find((c) => userId === c.UserId); + if (!userConfig) { + userConfig = { UserId: userId }; + config.UserList.push(userConfig); + } + + userConfig.EnableSynchronization = form.querySelector("#UserEnableSynchronization").checked; + userConfig.SyncUserDataOnImport = form.querySelector("#SyncUserDataOnImport").checked; + userConfig.SyncUserDataAfterPlayback = form.querySelector("#SyncUserDataAfterPlayback").checked; + userConfig.SyncUserDataUnderPlayback = form.querySelector("#SyncUserDataUnderPlayback").checked; + userConfig.SyncUserDataUnderPlaybackLive = form.querySelector("#SyncUserDataUnderPlaybackLive").checked; + userConfig.SyncUserDataInitialSkipEventCount = form.querySelector("#SyncUserDataInitialSkipEventCount").checked ? 2 : 0; + userConfig.SyncUserDataUnderPlaybackAtEveryXTicks = 6; + userConfig.SyncUserDataUnderPlaybackLiveThreshold = 125000000; // 12.5s + userConfig.SyncRestrictedVideos = form.querySelector("#SyncRestrictedVideos").checked; + if (!userConfig.Token) { + const username = form.querySelector("#UserUsername").value; + userConfig.Username = username; + } + } + break; + } + case "signalr": { + const reconnectIntervals = filterReconnectIntervals(form.querySelector("#SignalRAutoReconnectIntervals").value); + const libraryId = form.querySelector("#SignalRMediaFolderSelector").value; + const mediaFolders = libraryId ? config.MediaFolders.filter((m) => m.LibraryId === libraryId) : undefined; + + config.SignalR_AutoConnectEnabled = form.querySelector("#SignalRAutoConnect").checked; + config.SignalR_AutoReconnectInSeconds = reconnectIntervals; + form.querySelector("#SignalRAutoReconnectIntervals").value = reconnectIntervals.join(", "); + config.SignalR_EventSources = retrieveCheckboxList(form, "SignalREventSources"); + + config.SignalR_FileEvents = form.querySelector("#SignalRDefaultFileEvents").checked; + config.SignalR_RefreshEnabled = form.querySelector("#SignalRDefaultRefreshEvents").checked; + + if (mediaFolders) { + for (const c of mediaFolders) { + c.IsFileEventsEnabled = form.querySelector("#SignalRFileEvents").checked; + c.IsRefreshEventsEnabled = form.querySelector("#SignalRRefreshEvents").checked; + } + } + break; + } + + case "misc": { + const ignoredFolders = filterIgnoredFolders(form.querySelector("#IgnoredFolders").value); + + config.Misc_ShowInMenu = form.querySelector("#Misc_ShowInMenu").checked; + config.IgnoredFolders = ignoredFolders; + form.querySelector("#IgnoredFolders").value = ignoredFolders.join(", "); + + config.EXPERIMENTAL_MergeSeasons = form.querySelector("#EXPERIMENTAL_MergeSeasons").checked; + break; + } + } +} + +//#endregion + +//#region Configuration → Form + +/** + * Apply the given configuration to the form. + * + * @param {HTMLFormElement} form - The form element. + * @param {PluginConfiguration} config - The plugin configuration. + */ +async function applyConfigToForm(form, config) { + switch (State.currentTab) { + case "connection": { + form.querySelector("#Url").value = config.Url; + form.querySelector("#PublicUrl").value = config.PublicUrl; + form.querySelector("#Username").value = config.Username; + form.querySelector("#Password").value = ""; + break; + } + + case "metadata": { + if (form.querySelector("#TitleMainOverride").checked = config.TitleMainOverride) { + form.querySelector("#TitleMainList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleMainList").setAttribute("hidden", ""); + } + renderSortableCheckboxList(form, "TitleMainList", config.TitleMainList, config.TitleMainOrder); + if (form.querySelector("#TitleAlternateOverride").checked = config.TitleAlternateOverride) { + form.querySelector("#TitleAlternateList").removeAttribute("hidden"); + } + else { + form.querySelector("#TitleAlternateList").setAttribute("hidden", ""); + } + renderSortableCheckboxList(form, "TitleAlternateList", config.TitleAlternateList, config.TitleAlternateOrder); + form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; + form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; + if (form.querySelector("#DescriptionSourceOverride").checked = config.DescriptionSourceOverride) { + form.querySelector("#DescriptionSourceList").removeAttribute("hidden"); + } + else { + form.querySelector("#DescriptionSourceList").setAttribute("hidden", ""); + } + renderSortableCheckboxList(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; + form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#TagMaximumDepthContainer").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("#TagMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#TagMaximumDepthContainer").disabled = true; + } + renderCheckboxList(form, "TagSources", config.TagSources.split(",").map(s => s.trim()).filter(s => s)); + renderCheckboxList(form, "TagIncludeFilters", config.TagIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#TagMinimumWeight").value = config.TagMinimumWeight; + form.querySelector("#TagMaximumDepth").value = config.TagMaximumDepth.toString(); + 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; + form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); + form.querySelector("#GenreMaximumDepthContainer").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("#GenreMaximumDepthContainer").setAttribute("hidden", ""); + form.querySelector("#GenreMaximumDepthContainer").disabled = true; + } + renderCheckboxList(form, "GenreSources", config.GenreSources.split(",").map(s => s.trim()).filter(s => s)); + renderCheckboxList(form, "GenreIncludeFilters", config.GenreIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); + form.querySelector("#GenreMinimumWeight").value = config.GenreMinimumWeight; + form.querySelector("#GenreMaximumDepth").value = config.GenreMaximumDepth.toString(); + + if (form.querySelector("#ContentRatingOverride").checked = config.ContentRatingOverride) { + form.querySelector("#ContentRatingList").removeAttribute("hidden"); + } + else { + form.querySelector("#ContentRatingList").setAttribute("hidden", ""); + } + renderSortableCheckboxList(form, "ContentRatingList", config.ContentRatingList, config.ContentRatingOrder); + if (form.querySelector("#ProductionLocationOverride").checked = config.ProductionLocationOverride) { + form.querySelector("#ProductionLocationList").removeAttribute("hidden"); + } + else { + form.querySelector("#ProductionLocationList").setAttribute("hidden", ""); + } + renderSortableCheckboxList(form, "ProductionLocationList", config.ProductionLocationList, config.ProductionLocationOrder); + renderCheckboxList(form, "ThirdPartyIdProviderList", config.ThirdPartyIdProviderList.map(s => s.trim()).filter(s => s)); + break; + } + + case "library": { + const libraries = config.MediaFolders + .reduce((acc, mediaFolder) => { + if (mediaFolder.IsVirtualRoot) + return acc; + + if (acc.find((m) => m.LibraryId === mediaFolder.LibraryId)) + return acc; + + acc.push(mediaFolder); + return acc; + }, []); + + form.querySelector("#AutoMergeVersions").checked = config.AutoMergeVersions || false; + if (form.querySelector("#UseGroupsForShows").checked = config.UseGroupsForShows) { + 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("#SeparateMovies").checked = config.SeparateMovies; + form.querySelector("#FilterMovieLibraries").checked = config.FilterMovieLibraries; + form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; + form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes; + form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata; + + form.querySelector("#CollectionGrouping").value = config.CollectionGrouping; + form.querySelector("#CollectionMinSizeOfTwo").checked = config.CollectionMinSizeOfTwo; + + form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; + form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; + form.querySelector("#MediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + libraries + .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}</option>`) + .join(""); + break; + } + + case "vfs": { + form.querySelector("#AddTrailers").checked = config.AddTrailers; + form.querySelector("#AddCreditsAsThemeVideos").checked = config.AddCreditsAsThemeVideos; + form.querySelector("#AddCreditsAsSpecialFeatures").checked = config.AddCreditsAsSpecialFeatures; + form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; + form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; + + form.querySelector("#VFS_AttachRoot").checked = config.VFS_AttachRoot; + form.querySelector("#VFS_Location").value = config.VFS_Location; + form.querySelector("#VFS_CustomLocation").value = config.VFS_CustomLocation || ""; + form.querySelector("#VFS_CustomLocation").disabled = config.VFS_Location !== "Custom"; + if (config.VFS_Location === "Custom") { + form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); + } + else { + form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); + } + break; + } + + case "signalr": { + Dashboard.showLoadingMsg(); + const signalrStatus = await ShokoApiClient.getSignalrStatus(); + const libraries = config.MediaFolders + .reduce((acc, mediaFolder) => { + if (mediaFolder.IsVirtualRoot) + return acc; + + if (acc.find((m) => m.LibraryId === mediaFolder.LibraryId)) + return acc; + + acc.push(mediaFolder); + return acc; + }, []); + + updateSignalrStatus(form, signalrStatus); + + form.querySelector("#SignalRAutoConnect").checked = config.SignalR_AutoConnectEnabled; + form.querySelector("#SignalRAutoReconnectIntervals").value = config.SignalR_AutoReconnectInSeconds.join(", "); + renderCheckboxList(form, "SignalREventSources", config.SignalR_EventSources); + + form.querySelector("#SignalRDefaultFileEvents").checked = config.SignalR_FileEvents; + form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; + + form.querySelector("#SignalRMediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + libraries + .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}</option>`) + .join(""); + break; + } + + case "users": { + Dashboard.showLoadingMsg(); + const users = await ApiClient.getUsers(); + form.querySelector("#UserSelector").innerHTML = `<option value="">Click here to select a user</option>` + users.map((user) => `<option value="${user.Id}">${user.Name}</option>`).join(""); + break; + } + + case "misc": { + form.querySelector("#Misc_ShowInMenu").checked = config.Misc_ShowInMenu; + form.querySelector("#IgnoredFolders").value = config.IgnoredFolders.join(); + + form.querySelector("#EXPERIMENTAL_MergeSeasons").checked = config.EXPERIMENTAL_MergeSeasons || false; + break; + } + } +} + +/** + * Load the user configuration for the given user. + * + * @param {HTMLFormElement} form - The form element. + * @param {string} userId - The user ID. + * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @returns + */ +async function applyUserConfigToForm(form, userId, config = null) { + if (!userId) { + form.querySelector("#UserSettingsContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("required"); + return; + } + + // Get the configuration to use. + let shouldHide = false; + if (!config) { + if (State.config) { + config = State.config; + } + else { + Dashboard.showLoadingMsg(); + config = await ShokoApiClient.getConfiguration(); + shouldHide = true; + } + } + + // Configure the elements within the user container + const userConfig = config.UserList.find((c) => userId === c.UserId) || { UserId: userId }; + form.querySelector("#UserEnableSynchronization").checked = userConfig.EnableSynchronization || false; + form.querySelector("#SyncUserDataOnImport").checked = userConfig.SyncUserDataOnImport || false; + form.querySelector("#SyncUserDataAfterPlayback").checked = userConfig.SyncUserDataAfterPlayback || false; + form.querySelector("#SyncUserDataUnderPlayback").checked = userConfig.SyncUserDataUnderPlayback || false; + form.querySelector("#SyncUserDataUnderPlaybackLive").checked = userConfig.SyncUserDataUnderPlaybackLive || false; + form.querySelector("#SyncUserDataInitialSkipEventCount").checked = userConfig.SyncUserDataInitialSkipEventCount === 2; + form.querySelector("#SyncRestrictedVideos").checked = userConfig.SyncRestrictedVideos || false; + form.querySelector("#UserUsername").value = userConfig.Username || ""; + form.querySelector("#UserPassword").value = ""; + + // Synchronization settings + if (userConfig.Token) { + form.querySelector("#UserDeleteContainer").removeAttribute("hidden"); + form.querySelector("#UserUsername").setAttribute("disabled", ""); + form.querySelector("#UserPasswordContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("required"); + } + else { + form.querySelector("#UserDeleteContainer").setAttribute("hidden", ""); + form.querySelector("#UserUsername").removeAttribute("disabled"); + form.querySelector("#UserPasswordContainer").removeAttribute("hidden"); + form.querySelector("#UserUsername").setAttribute("required", ""); + } + + // Show the user settings now if it was previously hidden. + form.querySelector("#UserSettingsContainer").removeAttribute("hidden"); + + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } +} + +/** + * Load the VFS library configuration for the given library. + * + * @param {HTMLFormElement} form - The form element. + * @param {string} libraryId - The library ID. + * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @returns {Promise<void>} + */ +async function applyLibraryConfigToForm(form, libraryId, config = null) { + if (!libraryId) { + form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + return; + } + + // Get the configuration to use. + let shouldHide = false; + if (!config) { + if (State.config) { + config = State.config; + } + else { + Dashboard.showLoadingMsg(); + config = await ShokoApiClient.getConfiguration(); + shouldHide = true; + } + } + + const mediaFolders = State.config.MediaFolders.filter((c) => c.LibraryId === libraryId && !c.IsVirtualRoot); + if (!mediaFolders.length) { + renderReadonlyList(form, "MediaFolderImportFolderMapping", []); + + form.querySelector("#MediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } + return; + } + + renderReadonlyList(form, "MediaFolderImportFolderMapping", mediaFolders.map((c) => + c.IsMapped + ? `${c.MediaFolderPath} | ${c.ImportFolderName} (${c.ImportFolderId}) ${c.ImportFolderRelativePath}`.trimEnd() + : `${c.MediaFolderPath} | Not Mapped` + )); + + // Configure the elements within the media folder container + const libraryConfig = mediaFolders[0]; + form.querySelector("#MediaFolderVirtualFileSystem").checked = libraryConfig.IsVirtualFileSystemEnabled; + form.querySelector("#MediaFolderLibraryFilteringMode").value = libraryConfig.LibraryFilteringMode; + + // Show the media folder settings now if it was previously hidden. + form.querySelector("#MediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); + + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } +} + +/** + * Load the SignalR library configuration for the given library. + * + * @param {HTMLFormElement} form - The form element. + * @param {string} libraryId - The library ID. + * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @returns {Promise<void>} + */ +async function applySignalrLibraryConfigToForm(form, libraryId, config = null) { + if (!libraryId) { + form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + return; + } + + // Get the configuration to use. + let shouldHide = false; + if (!config) { + if (State.config) { + config = State.config; + } + else { + Dashboard.showLoadingMsg(); + config = await ShokoApiClient.getConfiguration(); + shouldHide = true; + } + } + + const libraryConfig = config.MediaFolders.find((c) => c.LibraryId === libraryId && !c.IsVirtualRoot); + if (!libraryConfig) { + form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").setAttribute("hidden", ""); + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } + return; + } + + // Configure the elements within the user container + form.querySelector("#SignalRFileEvents").checked = libraryConfig.IsFileEventsEnabled; + form.querySelector("#SignalRRefreshEvents").checked = libraryConfig.IsRefreshEventsEnabled; + + // Show the user settings now if it was previously hidden. + form.querySelector("#SignalRMediaFolderPerFolderSettingsContainer").removeAttribute("hidden"); + + if (shouldHide) { + Dashboard.hideLoadingMsg(); + } +} + +//#endregion + +//#region Server Interactions + +/** + * Default submit. Will conditionally sync settings or establish a new + * connection based on the current state of the local representation of the + * configuration. + * + * @param {HTMLFormElement} form - The form element. + * @returns {Promise<PluginConfiguration>} The updated plugin configuration. + */ +async function defaultSubmit(form) { + let config = State.config || await ShokoApiClient.getConfiguration(); + if (config.ApiKey !== "") { + return syncSettings(form, config); + } + + // Connection settings + let url = form.querySelector("#Url").value; + if (!url) { + url = "http://localhost:8111"; + } + else { + try { + let actualUrl = new URL(url); + url = actualUrl.href; + } + catch (err) { + try { + let actualUrl = new URL(`http://${url}:8111`); + url = actualUrl.href; + } + catch (err2) { + throw err; + } + } + } + if (url.endsWith("/")) { + url = url.slice(0, -1); + } + let publicUrl = form.querySelector("#PublicUrl").value; + if (publicUrl.endsWith("/")) { + publicUrl = publicUrl.slice(0, -1); + } + + // Update the url if needed. + if (config.Url !== url || config.PublicUrl !== publicUrl) { + config.Url = url; + config.PublicUrl = publicUrl; + form.querySelector("#Url").value = url; + form.querySelector("#PublicUrl").value = publicUrl; + await ShokoApiClient.updateConfiguration(config); + } + + const username = form.querySelector("#Username").value; + const password = form.querySelector("#Password").value; + try { + const response = await ShokoApiClient.getApiKey(username, password); + config = await ShokoApiClient.getConfiguration(); + config.Username = username; + config.ApiKey = response.apikey; + + await ShokoApiClient.updateConfiguration(config); + + Dashboard.hideLoadingMsg(); + Dashboard.alert(Messages.ConnectedToShoko); + } + catch (err) { + Dashboard.hideLoadingMsg(); + Dashboard.alert(Messages.InvalidCredentials); + console.error(err, Messages.InvalidCredentials); + } + + return config; +} + +/** + * Reset the connection to Shoko. + * + * @param {HTMLFormElement} form - The form element. + * @returns {Promise<PluginConfiguration>} The updated plugin configuration. + */ +async function resetConnection(form) { + const config = State.config || await ShokoApiClient.getConfiguration(); + form.querySelector("#Username").value = config.Username; + form.querySelector("#Password").value = ""; + + // Connection settings + config.ApiKey = ""; + config.ServerVersion = null; + + await ShokoApiClient.updateConfiguration(config); + + Dashboard.hideLoadingMsg(); + Dashboard.alert(Messages.DisconnectedToShoko); + + return config; +} + +/** + * Synchronize the settings with the server. + *1 + * @param {HTMLFormElement} form - The form element. + * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @returns {Promise<PluginConfiguration>} The updated plugin configuration. + */ +async function syncSettings(form, config) { + if (!config) { + config = State.config || await ShokoApiClient.getConfiguration(); + } + + applyFormToConfig(form, config); + + // User settings + const userId = form.querySelector("#UserSelector").value; + if (userId) { + let userConfig = config.UserList.find((c) => userId === c.UserId); + if (!userConfig) { + userConfig = { UserId: userId }; + config.UserList.push(userConfig); + } + + // Only try to save a new token if a token is not already present. + if (!userConfig.Token) { + try { + const username = form.querySelector("#UserUsername").value; + const password = form.querySelector("#UserPassword").value; + const response = await ShokoApiClient.getApiKey(username, password, true); + userConfig.Username = username; + userConfig.Token = response.apikey; + } + catch (err) { + Dashboard.alert(Messages.InvalidCredentials); + console.error(err, Messages.InvalidCredentials); + userConfig.Username = ""; + userConfig.Token = ""; + } + } + } + + config.UserList = config.UserList.filter((c) => c.Token); + + await ShokoApiClient.updateConfiguration(config); + Dashboard.processPluginConfigurationUpdateResult(); + + return config; +} + +/** + * Remove a user from the configuration. + * + * @param {HTMLFormElement} form - The form element. + * @returns {Promise<PluginConfiguration>} The updated plugin configuration. + */ +async function removeUserConfig(form) { + const config = State.config || await ShokoApiClient.getConfiguration(); + const userId = form.querySelector("#UserSelector").value; + if (!userId) return config; + + const index = config.UserList.findIndex(c => userId === c.UserId); + if (index !== -1) { + config.UserList.splice(index, 1); + } + + await ShokoApiClient.updateConfiguration(config); + Dashboard.processPluginConfigurationUpdateResult(); + + return config; +} + +/** + * Remove a library from the configuration. + * + * @param {HTMLFormElement} form - The form element. + * @returns {Promise<PluginConfiguration>} The updated plugin configuration. + */ +async function removeLibraryConfig(form) { + const config = State.config || await ShokoApiClient.getConfiguration(); + const libraryId = form.querySelector("#MediaFolderSelector").value; + if (!libraryId) return config; + + let index = config.MediaFolders.findIndex((m) => m.LibraryId === libraryId); + while (index !== -1) { + config.MediaFolders.splice(index, 1); + index = config.MediaFolders.findIndex((m) => m.LibraryId === libraryId); + } + + + const libraries = config.MediaFolders + .reduce((acc, mediaFolder) => { + if (mediaFolder.IsVirtualRoot) + return acc; + + if (acc.find((m) => m.LibraryId === mediaFolder.LibraryId)) + return acc; + + acc.push(mediaFolder); + return acc; + }, []); + form.querySelector("#MediaFolderSelector").value = ""; + form.querySelector("#MediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + libraries + .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}</option>`) + .join(""); + form.querySelector("#SignalRMediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + libraries + .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}</option>`) + .join(""); + + await ShokoApiClient.updateConfiguration(config); + Dashboard.processPluginConfigurationUpdateResult(); + + return config; +} + +/** + * Toggle expert mode. + * + * @param {boolean} value - True to enable expert mode, false to disable it. + * @returns {Promise<PluginConfiguration>} The updated plugin configuration. + */ +async function toggleExpertMode(value) { + const config = State.config || await ShokoApiClient.getConfiguration(); + + config.ExpertMode = value; + + await ShokoApiClient.updateConfiguration(config); + + Dashboard.alert(value ? Messages.ExpertModeEnabled : Messages.ExpertModeDisabled); + + return config; +} + +//#endregion + +//#region Helpers +/** + * Filter out duplicate values and sanitize list. + * @param {string} value - Stringified list of values to filter. + * @returns {string[]} An array of sanitized and filtered values. + */ +function filterIgnoredFolders(value) { + // We convert to a set to filter out duplicate values. + const filteredSet = new Set( + value + // Split the values at every comma. + .split(",") + // Sanitize inputs. + .map(str => str.trim().toLowerCase()) + .filter(str => str), + ); + + // Convert it back into an array. + return Array.from(filteredSet); +} + +/** + * Filter out duplicate values and sanitize list. + * @param {string} value - Stringified list of values to filter. + * @returns {number[]} An array of sanitized and filtered values. + */ +function filterReconnectIntervals(value) { + // We convert to a set to filter out duplicate values. + const filteredSet = new Set( + value + // Split the values at every comma. + .split(",") + // Sanitize inputs. + .map(str => parseInt(str.trim().toLowerCase(), 10)) + .filter(int => !Number.isNaN(int)), + ); + + // Convert it back into an array. + return Array.from(filteredSet).sort((a, b) => a - b); +} + +//#endregion \ No newline at end of file diff --git a/Shokofin/Pages/Scripts/jsconfig.json b/Shokofin/Pages/Scripts/jsconfig.json new file mode 100644 index 00000000..0a3c93a0 --- /dev/null +++ b/Shokofin/Pages/Scripts/jsconfig.json @@ -0,0 +1,17 @@ +{ + "include": ["**/*"], + "compilerOptions": { + "module": "ESNext", + "moduleResolution": "Node", + "target": "ES2020", + "lib": ["ES2020", "DOM"], + "allowSyntheticDefaultImports": true, + "noImplicitAny": false, + "experimentalDecorators": true, + "emitDecoratorMetadata": true, + "jsx": "react", + "sourceMap": true, + "outDir": "dist", + "baseUrl": ".", + }, +} \ No newline at end of file diff --git a/Shokofin/Configuration/configPage.html b/Shokofin/Pages/Settings.html similarity index 87% rename from Shokofin/Configuration/configPage.html rename to Shokofin/Pages/Settings.html index d5db839f..39035829 100644 --- a/Shokofin/Configuration/configPage.html +++ b/Shokofin/Pages/Settings.html @@ -1,19 +1,19 @@ -<div id="ShokoConfigPage" data-role="page" class="page type-interior pluginConfigurationPage" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/ShokoController.js"> +<div data-role="page" class="page type-interior pluginConfigurationPage withTabs" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/Shoko.Settings.js"> <div data-role="content"> <div class="content-primary"> <style>form:not(.expert-mode) .expert-only { display: none !important; }</style> - <form id="ShokoConfigForm"> + <form> <div class="verticalSection verticalSection-extrabottompadding"> <div class="sectionTitleContainer flex align-items-center"> <h2 class="sectionTitle">Shoko</h2> <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton emby-button" target="_blank" href="https://docs.shokoanime.com/shokofin/configuration/">Help</a> </div> - <fieldset id="ConnectionSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="Connection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Connection Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Url" required label="Private Host Url:" /> + <input is="emby-input" type="text" id="Url" label="Private Host Url:" /> <div class="fieldDescription">This is the private URL leading to where Shoko is running. It will be used internally in Jellyfin and also for all images sent to clients and redirects back to the Shoko instance if you don't set a public host URL below. It <i>should</i> include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> @@ -21,7 +21,7 @@ <h3>Connection Settings</h3> <div class="fieldDescription">Optional. This is the public URL leading to where Shoko is running. It can be used to redirect to Shoko if you click on a Shoko ID in the UI if Shoko and/or Jellyfin is running within a container and you cannot access Shoko from the host URL provided in the connection settings section above. It will also be used for images from the plugin when viewing the "Edit Images" modal in clients. It <i>should</i> include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Username" required label="Username:" /> + <input is="emby-input" type="text" id="Username" label="Username:" /> <div class="fieldDescription">The user should be an administrator in Shoko, preferably without any filtering applied.</div> </div> <div id="ConnectionSetContainer"> @@ -45,17 +45,14 @@ <h3>Connection Settings</h3> <div class="fieldDescription">Reset the connection. Be sure to stop any tasks using this plugin before you press the button.</div> </div> </fieldset> - <fieldset id="MetadataSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="Metadata_Title_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> - <h3>Metadata Settings</h3> + <h3>Title Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding"> - Customize how the plugin will source the metadata for entries. - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Add prefix to episodes</span> + <span>Add prefix to episode titles</span> </label> <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> </div> @@ -66,20 +63,6 @@ <h3>Metadata Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Will add any title in the selected language if no official title is found.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> - <span>Cleanup AniDB overviews</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> - <span>Ignore unverified tags.</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleMainOverride" /> @@ -89,7 +72,7 @@ <h3>Metadata Settings</h3> Enables the advanced selector for the main title selection. </div> </div> - <div id="TitleMainList" class="expert-only" style="margin-bottom: 2em;"> + <div id="TitleMainList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced main title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> @@ -188,7 +171,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of Enables the advanced selector for the alternate title selection. </div> </div> - <div id="TitleAlternateList" class="expert-only" style="margin-bottom: 2em;"> + <div id="TitleAlternateList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced alternate title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> @@ -278,6 +261,21 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </div> <div class="fieldDescription">The metadata providers to use as the source of the alternate title, in priority order.</div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="Metadata_Description_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <legend> + <h3>Description Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> + <span>Cleanup AniDB overviews</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="DescriptionSourceOverride" /> @@ -287,7 +285,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of Enables the advanced selector for description source selection. </div> </div> - <div id="DescriptionSourceList" class="expert-only" style="margin-bottom: 2em;"> + <div id="DescriptionSourceList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="Shoko"> @@ -343,6 +341,21 @@ <h3 class="listItemBodyText">TMDB</h3> </div> <div class="fieldDescription">The metadata providers to use as the source of descriptions, in priority order.</div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="Metadata_TagGenre_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <legend> + <h3>Tag & Genre Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> + <span>Ignore unverified tags.</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TagOverride" /> @@ -352,7 +365,7 @@ <h3 class="listItemBodyText">TMDB</h3> Enables the advanced selector for tag source selection. </div> </div> - <div id="TagSources" class="expert-only" style="margin-bottom: 2em;"> + <div id="TagSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced tag sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -583,7 +596,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> </div> <div class="fieldDescription">The tag sources to use as the source of tags.</div> </div> - <div id="TagIncludeFilters" class="expert-only" style="margin-bottom: 2em;"> + <div id="TagIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced tag include filters:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -680,7 +693,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> Enables the advanced selector for genre source selection. </div> </div> - <div id="GenreSources" class="expert-only" style="margin-bottom: 2em;"> + <div id="GenreSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced genre sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -911,7 +924,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> </div> <div class="fieldDescription">The tag sources to use as the source of genres.</div> </div> - <div id="GenreIncludeFilters" class="expert-only" style="margin-bottom: 2em;"> + <div id="GenreIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced genre include filters:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -999,6 +1012,14 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <input is="emby-input" id="GenreMaximumDepth" label="Maximum depth to add per genre source:" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for genres.</div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="Metadata_Misc_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <legend> + <h3>Miscellaneous Settings</h3> + </legend> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="ContentRatingOverride" /> @@ -1008,7 +1029,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> Enables the advanced selector for content rating source selection. </div> </div> - <div id="ContentRatingList" class="expert-only" style="margin-bottom: 2em;"> + <div id="ContentRatingList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced content rating sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="TMDB"> @@ -1047,7 +1068,7 @@ <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h Enables the advanced selector for production location source selection. </div> </div> - <div id="ProductionLocationList" class="expert-only" style="margin-bottom: 2em;"> + <div id="ProductionLocationList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Advanced production location sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="AniDB"> @@ -1077,7 +1098,15 @@ <h3 class="listItemBodyText">TMDB</h3> </div> <div class="fieldDescription">The metadata providers to use as the source of production locations, in priority order.</div> </div> - <div id="ThirdPartyIdProviderList" style="margin-bottom: 2em;"> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="Metadata_ThirdPartyIntegration_Section" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Third Party Integration</h3> + </legend> + <div id="ThirdPartyIdProviderList" is="checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Add Third Party IDs:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -1114,13 +1143,10 @@ <h3 class="listItemBodyText">TvDB</h3> <span>Save</span> </button> </fieldset> - <fieldset id="LibrarySection" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="Library_Basic_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> - <h3>Library Settings</h3> + <h3>Basic Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding"> - Placeholder description. - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AutoMergeVersions" /> @@ -1155,16 +1181,6 @@ <h3>Library Settings</h3> </details> </div> </div> - <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription expert-only" hidden> - <label class="selectLabel" for="SeasonOrdering">Season ordering:</label> - <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> - <option value="Default" selected>Let Shoko decide</option> - <option value="ReleaseDate">Order seasons by release date</option> - <option value="Chronological">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> - <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> - </select> - <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SeparateMovies" /> @@ -1179,6 +1195,16 @@ <h3>Library Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">This filters out anything that is not a movie in any movie libraries. Disable this if you want your anything to show up as movies in your movie libraries instead.</div> </div> + <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription expert-only" hidden> + <label class="selectLabel" for="SeasonOrdering">Season ordering:</label> + <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> + <option value="Default" selected>Let Shoko decide</option> + <option value="ReleaseDate">Order seasons by release date</option> + <option value="Chronological" class="expert-only">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> + <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> + </select> + <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> + </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SpecialsPlacement">Specials placement within seasons:</label> <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> @@ -1190,6 +1216,28 @@ <h3>Library Settings</h3> </select> <div class="fieldDescription selectFieldDescription">Determines how specials are placed within seasons. <strong>Warning:</strong> Modifying this setting requires a recreation (read as; delete existing then create a new) of any libraries using this plugin — otherwise you <strong>will</strong> have mixed metadata.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> + <span>Force movie special features</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special features for the movies. By default only some specials will be automatically recognized as special features, but by enabling this option you will force all specials to be used as special features. This setting applies to movie series across all library types, and may break some movie series in a show type library unless appropriate measures are taken.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> + <span>Add Missing Episodes/Seasons</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add the metadata for missing episodes/seasons not in the local collection.</div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="Library_Collection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Collection Settings</h3> + </legend> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="CollectionGrouping">Collections:</label> <select is="emby-select" id="CollectionGrouping" name="CollectionGrouping" class="emby-select-withcolor emby-select"> @@ -1230,127 +1278,79 @@ <h3>Library Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Add a minimum requirement of two entries with the same collection id before creating a collection for them.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> - <span>Force movie special features</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special features for the movies. By default only some specials will be automatically recognized as special features, but by enabling this option you will force all specials to be used as special features. This setting applies to movie series across all library types, and may break some movie series in a show type library unless appropriate measures are taken.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="AddTrailers" /> - <span>Add trailers</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Add trailers to entities within the VFS. Trailers within the trailers directory when not using the VFS are not affected by this option.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="AddCreditsAsThemeVideos" /> - <span>Add credits as theme videos</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Add all credits as theme videos to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="AddCreditsAsSpecialFeatures" /> - <span>Add credits as special features</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Add all credits as special features to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> - <span>Add Missing Episodes/Seasons</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Add the metadata for missing episodes/seasons not in the local collection.</div> - </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> </fieldset> - <fieldset id="MediaFolderSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="Library_New_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> - <h3>Media Folder Settings</h3> + <h3>New Library Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding"> - Placeholder description. - </div> - <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> - <div class="fieldDescription">A comma separated list of folder names which will be ignored during library filtering.</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="VFS_AddReleaseGroup" /> - <span>Add Release Group to VFS entries</span> + <input is="emby-checkbox" type="checkbox" id="VFS_Enabled" /> + <span>Use the Virtual File System (<strong>VFS</strong>)</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the full or short release group name to all automatically linked files in the VFS, and "No Group" for all manually linked files in the VFS. <strong>Warning</strong>: The release group in the file name may change if the release group info is incomplete, unavailable, or otherwise updated in Shoko at a later date, and thus may cause episode/movie entries to be "removed" and "added" as new entries when that happens. <strong>Use at your own risk.</strong></div> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enables the use of the VFS for any new media libraries managed by the plugin.</div> + <div id="WindowsSymLinkWarning1" hidden><strong>Warning</strong>: Windows users are required to <a href="https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging#use-regedit-to-enable-your-device" target="_blank" rel="noopener noreferrer">enable Developer Mode</a> then restart Jellyfin to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does this mean?</summary> + Enabling this setting should in theory make it so you won't have to think about file structure incompatibilities, since it will override your existing file structure and replace it with a layer of symbolic links managed by the plugin instead of your actual file structure. +   + <strong>Do not enable this for an existing library. If you do, then delete and re-create your library from scratch.</strong> + </details> + </div> </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="VFS_AddResolution" /> - <span>Add Resolution to VFS entries</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the standardized resolution (e.g. 480p, 1080p, 4K, etc.) to all files in the VFS, <strong>IF</strong> it's available. <strong>Warning</strong>: Though rare, we may have failed to read the media info in Shoko when the files were first added (e.g. because of a corrupt file, encountering an unsupported <i>new</i> codec, etc.), then reading it later. This may lead to episode/movie entries to be "removed" and "added" as new entries the next time they are refreshed after the metadata has been added. <strong>Use at your own risk.</strong></div> + <div class="selectContainer selectContainer-withDescription"> + <label class="selectLabel" for="LibraryFilteringMode">Legacy Library Filtering:</label> + <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select"> + <option value="Auto">Auto</option> + <option value="Strict" selected>Strict</option> + <option value="Lax" selected>Lax</option> + </select> + <div class="fieldDescription"> + <div>Choose how the plugin filters out videos in your new libraries. This option only applies if the VFS is not used.</div> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does auto filtering entail?</summary> + Auto filtering means the plugin will only filter out unrecognized videos if no other metadata provider is enabled, otherwise it will leave them in the library. + </details> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does strict filtering entail?</summary> + Strict filtering means the plugin will filter out any and all unrecognized videos from the library. + </details> + <details style="margin-top: 0.5em"> + <summary style="margin-bottom: 0.25em">What does lax filtering entail?</summary> + Lax filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. + </details> + </div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="Library_Existing_Section" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Existing Library Settings</h3> + </legend> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="MediaFolderSelector">Configure settings for:</label> <select is="emby-select" id="MediaFolderSelector" name="MediaFolderSelector" value="" class="emby-select-withcolor emby-select"> - <option value="">Default settings for new media folders</option> + <option value="">Click here to select a library</option> </select> <div class="fieldDescription selectFieldDescription">Select a media folder to add or modify the media folder settings for.</div> </div> - <div id="MediaFolderDefaultSettingsContainer"> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="VFS_Enabled" /> - <span>Virtual File System (<strong>VFS</strong>)</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - <div>Enables the use of the VFS for any new media libraries managed by the plugin.</div> - <div id="WindowsSymLinkWarning1" hidden><strong>Warning</strong>: Windows users are required to <a href="https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging#use-regedit-to-enable-your-device" target="_blank" rel="noopener noreferrer">enable Developer Mode</a> then restart Jellyfin to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</div> - <details style="margin-top: 0.5em"> - <summary style="margin-bottom: 0.25em">What does this mean?</summary> - Enabling this setting should in theory make it so you won't have to think about file structure incompatibilities, since it will override your existing file structure and replace it with a layer of symbolic links managed by the plugin instead of your actual file structure. -   - <strong>Do not enable this for an existing library. If you do, then delete and re-create your library from scratch.</strong> - </details> - </div> - </div> - <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="LibraryFilteringMode">Library Filtering:</label> - <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select"> - <option value="Auto">Auto</option> - <option value="Strict" selected>Strict</option> - <option value="Lax" selected>Lax</option> - </select> - <div class="fieldDescription"> - <div>Choose how the plugin filters out videos in your new libraries. This option only applies if the VFS is not used.</div> - <details style="margin-top: 0.5em"> - <summary style="margin-bottom: 0.25em">What does auto filtering entail?</summary> - Auto filtering means the plugin will only filter out unrecognized videos if no other metadata provider is enabled, otherwise it will leave them in the library. - </details> - <details style="margin-top: 0.5em"> - <summary style="margin-bottom: 0.25em">What does strict filtering entail?</summary> - Strict filtering means the plugin will filter out any and all unrecognized videos from the library. - </details> - <details style="margin-top: 0.5em"> - <summary style="margin-bottom: 0.25em">What does lax filtering entail?</summary> - Lax filtering means the plugin will not filter out anything from the library. Use this only if you're having trouble. - </details> - </div> - </div> - </div> <div id="MediaFolderPerFolderSettingsContainer" hidden> - <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="MediaFolderImportFolderName" label="Mapped Import Folder:" disabled readonly value="-" /> - <div class="fieldDescription">The Shoko Import Folder the Media Folder is mapped to.</div> + <div id="MediaFolderImportFolderMapping" style="margin-bottom: 2em;"> + <h3 class="checkboxListLabel">Import Folder Mapping:</h3> + <div class="checkboxList paperList checkboxList-paperList"> + </div> + <div class="fieldDescription">The Shoko Import Folders the Media Folders are mapped to.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MediaFolderVirtualFileSystem" /> - <span>Virtual File System (<strong>VFS</strong>)</span> + <span>Use the Virtual File System (<strong>VFS</strong>)</span> </label> <div class="fieldDescription checkboxFieldDescription"> <div>Enables the use of the VFS for the library.</div> @@ -1364,7 +1364,7 @@ <h3>Media Folder Settings</h3> </div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="MediaFolderLibraryFilteringMode">Library Filtering:</label> + <label class="selectLabel" for="MediaFolderLibraryFilteringMode">Legacy Library Filtering:</label> <select is="emby-select" id="MediaFolderLibraryFilteringMode" name="MediaFolderLibraryFilteringMode" class="emby-select-withcolor emby-select"> <option value="Auto">Auto</option> <option value="Strict" selected>Strict</option> @@ -1386,23 +1386,113 @@ <h3>Media Folder Settings</h3> </details> </div> </div> - </div> - <div class="inputContainer inputContainer-withDescription" hidden> - <button is="emby-button" type="submit" name="remove-media-folder" class="raised button-delete block emby-button"> - <span>Delete</span> + <div class="inputContainer inputContainer-withDescription"> + <button is="emby-button" type="submit" name="remove-library" class="raised button-delete block emby-button"> + <span>Remove</span> + </button> + <div class="fieldDescription">This will delete the saved settings and reset the mapping for the library.</div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> </button> - <div class="fieldDescription">This will delete the saved settings and reset the mapping for the media folder.</div> </div> - <button is="emby-button" type="submit" name="media-folder-settings" class="raised button-submit block emby-button"> + </fieldset> + <fieldset id="Library_Experimental_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <legend> + <h3>Experimental Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding">Any features/settings in this section is still considered to be in an experimental state. <strong>You can enable them, but at the risk if them messing up your library.</strong></div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> + <span>Automatically merge seasons</span> + </label> + <div class="fieldDescription checkboxFieldDescription"><div>Blur the boundaries between AniDB anime further by merging entries which could had just been a single anime entry based on name matching and a configurable merge window.</div></div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="VFS_Basic_Section" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Basic Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddTrailers" /> + <span>Add Trailers</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add trailers to entities within the VFS. Trailers within the trailers directory when not using the VFS are not affected by this option.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddCreditsAsThemeVideos" /> + <span>Add Credits as Theme Videos</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add all credits as theme videos to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddCreditsAsSpecialFeatures" /> + <span>Add Credits as Special Features</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add all credits as special features to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_AddReleaseGroup" /> + <span>Add Release Group to Path</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the full or short release group name to all automatically linked files in the VFS, and "No Group" for all manually linked files in the VFS. <strong>Warning</strong>: The release group in the file name may change if the release group info is incomplete, unavailable, or otherwise updated in Shoko at a later date, and thus may cause episode/movie entries to be "removed" and "added" as new entries when that happens. <strong>Use at your own risk.</strong></div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_AddResolution" /> + <span>Add Resolution to Path</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the standardized resolution (e.g. 480p, 1080p, 4K, etc.) to all files in the VFS, <strong>IF</strong> it's available. <strong>Warning</strong>: Though rare, we may have failed to read the media info in Shoko when the files were first added (e.g. because of a corrupt file, encountering an unsupported <i>new</i> codec, etc.), then reading it later. This may lead to episode/movie entries to be "removed" and "added" as new entries the next time they are refreshed after the metadata has been added. <strong>Use at your own risk.</strong></div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="VFS_Location_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <legend> + <h3>VFS Location Settings</h3> + </legend> + <div class="fieldDescription verticalSection-extrabottompadding"> + Configure where to place the VFS in the file system and how to attach it to your libraries. <strong>Tweak at your own risk.</strong> + </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_AttachRoot" /> + <span>Physically Attach VFS to Libraries</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Enabling this will attach a physical VFS "root" to your libraries as a media folder, allowing us to mount the VFS children to the new "root" and to re-use media folders across libraries without resorting to <i>other</i> workarounds.</div> + </div> + <div id="VFS_LocationContainer" class="selectContainer selectContainer-withDescription expert-only"> + <label class="selectLabel" for="VFS_Location">VFS Location:</label> + <select is="emby-select" id="VFS_Location" name="VFS_Location" class="emby-select-withcolor emby-select"> + <option value="Default" selected>Jellyfin Data Directory (Default)</option> + <option value="Cache">Jellyfin Cache Directory</option> + <option value="Custom">Custom Directory</option> + </select> + <div class="fieldDescription">Determines where the VFS root will be placed. Changing this will cause your library to "remove" and "re-add" itself because of the path changes. You will need to manually move your VFS root if you plan to keep it when toggling this setting, or you can get rid of it by running the scheduled task to remove it. <strong>You have been warned.</strong></div> + </div> + <div id="VFS_CustomLocationContainer" class="inputContainer inputContainer-withDescription expert-only" hidden> + <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location:" disabled /> + <div class="fieldDescription">An absolute path to the custom root directory of where the VFS will be placed. You decide.</div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> </fieldset> - <fieldset id="SignalRSection1" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="SignalR_Connection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> - <h3>SignalR Connection</h3> + <h3>Connection Status</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="SignalRStatus" label="Status:" disabled readonly value="Inactive"> + <input is="emby-input" type="text" id="SignalRStatus" label="Connection Status:" disabled readonly value="Inactive"> <div class="fieldDescription">SignalR connection status.</div> </div> <div id="SignalRConnectContainer" hidden> @@ -1418,9 +1508,9 @@ <h3>SignalR Connection</h3> <div class="fieldDescription">Terminate the SignalR connection to Shoko Server.</div> </div> </fieldset> - <fieldset id="SignalRSection2" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="SignalR_Basic_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> - <h3>SignalR Settings</h3> + <h3>Basic Settings</h3> </legend> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> @@ -1435,7 +1525,7 @@ <h3>SignalR Settings</h3> <input is="emby-input" type="text" id="SignalRAutoReconnectIntervals" label="Auto Reconnect Intervals:" /> <div class="fieldDescription">A comma separated list of intervals in seconds to try re-establish the connection if the plugin gets disconnected from Shoko Server.</div> </div> - <div id="SignalREventSources" class="expert-only" style="margin-bottom: 2em;"> + <div id="SignalREventSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Event Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> @@ -1468,38 +1558,48 @@ <h3 class="listItemBodyText">TMDB</h3> </div> <div class="fieldDescription">Which event sources should be listened to via the SignalR connection.</div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="SignalR_Library_New_Section" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>New Library Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRDefaultFileEvents" /> + <span>File Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enables SignalR file events and real-time monitoring events for any new libraries.</div> + </div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="SignalRDefaultRefreshEvents" /> + <span>Metadata Events</span> + </label> + <div class="fieldDescription checkboxFieldDescription"> + <div>Enables SignalR metadata update events for any new libraries.</div> + </div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> + <fieldset id="SignalR_Library_Existing_Section" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Existing Library Settings</h3> + </legend> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SignalRMediaFolderSelector">Configure settings for:</label> <select is="emby-select" id="SignalRMediaFolderSelector" name="SignalRMediaFolderSelector" value="" class="emby-select-withcolor emby-select"> - <option value="">Default settings for new media folders</option> + <option value="">Click here to select a library</option> </select> <div class="fieldDescription selectFieldDescription">Select a media folder to add or modify the SignalR settings for.</div> </div> - <div id="SignalRMediaFolderDefaultSettingsContainer"> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="SignalRDefaultFileEvents" /> - <span>File Events</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - <div>Enables SignalR file events and real-time monitoring events for any new media folders.</div> - </div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="SignalRDefaultRefreshEvents" /> - <span>Metadata Events</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - <div>Enables SignalR metadata update events for any new media folders.</div> - </div> - </div> - </div> <div id="SignalRMediaFolderPerFolderSettingsContainer" hidden> - <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="SignalRMediaFolderImportFolderName" label="Mapped Import Folder:" disabled readonly value="-" /> - <div class="fieldDescription">The Shoko Import Folder the Media Folder is mapped to.</div> - </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SignalRFileEvents" /> @@ -1518,12 +1618,12 @@ <h3 class="listItemBodyText">TMDB</h3> <div>Enables SignalR metadata update events for the media folder.</div> </div> </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> </div> - <button is="emby-button" type="submit" name="signalr-settings" class="raised button-submit block emby-button"> - <span>Save</span> - </button> </fieldset> - <fieldset id="UserSection" class="verticalSection verticalSection-extrabottompadding" hidden> + <fieldset id="User_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>User Settings</h3> </legend> @@ -1594,51 +1694,45 @@ <h3>User Settings</h3> </div> <div id="UserDeleteContainer" class="inputContainer inputContainer-withDescription" hidden> <button is="emby-button" type="submit" name="unlink-user" class="raised button-delete block emby-button"> - <span>Delete</span> + <span>Remove</span> </button> <div class="fieldDescription">This will delete any saved settings for the user.</div> </div> - <button is="emby-button" type="submit" name="user-settings" class="raised button-submit block emby-button"> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> </div> </fieldset> - <fieldset id="ExperimentalSection" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <fieldset id="Misc_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> <legend> - <h3>Experimental Settings</h3> + <h3>Miscellaneous Settings</h3> </legend> - <div class="fieldDescription verticalSection-extrabottompadding">Any features/settings in this section is still considered to be in an experimental state. <strong>You can enable them, but at the risk if them messing up your library.</strong></div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="VFS_AttachRoot" /> - <span>Attach physical root for VFS</span> + <input is="emby-checkbox" type="checkbox" id="Misc_ShowInMenu" /> + <span>Show in Menu</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will attach a physical VFS "root" to your libraries as a media folder, allowing us to mount the VFS children to the new "root" and to re-use media folders across libraries without resorting to <i>other</i> workarounds.</div> + <div class="fieldDescription checkboxFieldDescription">Shows a shortcut to the plugin in the sidebar navigation menu.</div> </div> - <div id="VFS_LocationContainer" class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="VFS_Location">VFS Location:</label> - <select is="emby-select" id="VFS_Location" name="VFS_Location" class="emby-select-withcolor emby-select"> - <option value="Default" selected>Jellyfin Data Directory (Default)</option> - <option value="Cache">Jellyfin Cache Directory</option> - <option value="Custom">Custom Directory</option> - </select> - <div class="fieldDescription">Determines where the VFS root will be placed. Changing this will cause your library to "remove" and "re-add" itself becaue of the path changes. You will need to manually move your VFS root if you plan to keep it when toggling this setting, or you can get rid of it by running the scheduled task to remove it. <strong>You have been warned.</strong></div> - </div> - <div id="VFS_CustomLocationContainer" class="inputContainer inputContainer-withDescription expert-only" hidden> - <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location:" disabled /> - <div class="fieldDescription">An absolute path to the custom root directory of where the VFS will be placed. You decide.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> - <span>Automatically merge seasons</span> - </label> - <div class="fieldDescription checkboxFieldDescription"><div>Blur the boundaries between AniDB anime further by merging entries which could had just been a single anime entry based on name matching and a configurable merge window.</div></div> + <div class="inputContainer inputContainer-withDescription expert-only"> + <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> + <div class="fieldDescription">A comma separated list of folder names which will be ignored during library filtering.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> </fieldset> + <fieldset id="Utilities_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <legend> + <h3>Utilities</h3> + </legend> + <div id="UserDeleteContainer" class="inputContainer inputContainer-withDescription expert-only"> + <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt block emby-button link-redirection" target="_blank" href="Shoko.Utilities.Dummy"> + <span>Dummy Example</span> + </a> + <div class="fieldDescription">Don't click the button above! You dummy!</div> + </div> + </fieldset> </div> </form> </div> diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index bb20b431..9abb440d 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -227,15 +227,38 @@ public IEnumerable<PluginPageInfo> GetPages() { return [ + // HTML new PluginPageInfo { - Name = Name, - EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configPage.html", + Name = "Shoko.Settings", + EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Settings.html", + EnableInMainMenu = Configuration.Misc_ShowInMenu, + DisplayName = "Shoko - Settings", + MenuSection = "Shoko", }, new PluginPageInfo { - Name = "ShokoController.js", - EmbeddedResourcePath = $"{GetType().Namespace}.Configuration.configController.js", + Name = "Shoko.Utilities.Dummy", + EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Dummy.html", + DisplayName = "Shoko - Dummy", + MenuSection = "Shoko", + }, + + // JS + new PluginPageInfo + { + Name = "Shoko.Common.js", + EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Scripts.Common.js", + }, + new PluginPageInfo + { + Name = "Shoko.Settings.js", + EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Scripts.Settings.js", + }, + new PluginPageInfo + { + Name = "Shoko.Utilities.Dummy.js", + EmbeddedResourcePath = $"{GetType().Namespace}.Pages.Scripts.Dummy.js", }, ]; } diff --git a/Shokofin/Shokofin.csproj b/Shokofin/Shokofin.csproj index a0c292aa..c731c4ab 100644 --- a/Shokofin/Shokofin.csproj +++ b/Shokofin/Shokofin.csproj @@ -25,9 +25,7 @@ </Target> <ItemGroup> - <None Remove="Configuration\configController.js" /> - <None Remove="Configuration\configPage.html" /> - <EmbeddedResource Include="Configuration\configController.js" /> - <EmbeddedResource Include="Configuration\configPage.html" /> + <None Remove="Pages\**\*" /> + <EmbeddedResource Include="Pages\**\*" /> </ItemGroup> </Project> From 7687480ce4c846e4536652ab2aba7317fcc192b7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Sep 2024 03:25:11 +0200 Subject: [PATCH 086/144] fix: better error handling during VFS generation - Add configurable limits for when the link generation should be aborted. - Skip link generation for remaining files if we exceed the configured limit. - Throw an `AggregateException` if any series exceeded the maximum number of exceptions, or if the total number of exceptions exceeded the maximum allowed. Additionally, if no links were generated and there were any exceptions, but we haven't reached the maximum allowed exceptions yet, then also throw an `AggregateException`. --- Shokofin/Configuration/PluginConfiguration.cs | 15 ++++++ Shokofin/Events/EventDispatchService.cs | 12 ++--- .../Resolvers/VirtualFileSystemService.cs | 49 ++++++++++++++----- 3 files changed, 59 insertions(+), 17 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 1a5b16f6..62881416 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -417,6 +417,19 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool VFS_AttachRoot { get; set; } + /// <summary> + /// Maximum number of exceptions before aborting the VFS generation. + /// </summary> + [Range(-1, 1000)] + public int VFS_MaxTotalExceptionsBeforeAbort { get; set; } + + /// <summary> + /// Maximum number of series with exceptions before aborting the VFS + /// generation. + /// </summary> + [Range(-1, 100)] + public int VFS_MaxSeriesExceptionsBeforeAbort { get; set; } + /// <summary> /// Places the VFS in the cache directory instead of the config directory. /// </summary> @@ -606,6 +619,8 @@ public PluginConfiguration() VFS_AttachRoot = false; VFS_Location = VirtualRootLocation.Default; VFS_CustomLocation = null; + VFS_MaxTotalExceptionsBeforeAbort = 10; + VFS_MaxSeriesExceptionsBeforeAbort = 3; AutoMergeVersions = true; UseGroupsForShows = false; SeparateMovies = false; diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index 7351711a..c8164541 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -231,10 +231,10 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var result = new LinkGenerationResult(); var topFolders = new HashSet<string>(); var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(collectionType, vfsPath, sourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .Where(tuple => tuple.symbolicLinks.Length > 0 && tuple.importedAt.HasValue) .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, importDate!.Value); + foreach (var (symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(sourceLocation, symLinks, importDate!.Value); foreach (var path in symLinks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) topFolders.Add(path); } @@ -301,10 +301,10 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int var newSourceLocation = await GetNewSourceLocation(importFolderId, importFolderSubPath, fileId, relativePath, mediaFolderPath); if (!string.IsNullOrEmpty(newSourceLocation)) { var vfsLocations = (await Task.WhenAll(seriesIds.Select(seriesId => ResolveManager.GenerateLocationsForFile(collectionType, vfsPath, newSourceLocation, fileId.ToString(), seriesId))).ConfigureAwait(false)) - .Where(tuple => !string.IsNullOrEmpty(tuple.sourceLocation) && tuple.importedAt.HasValue) + .Where(tuple => tuple.symbolicLinks.Length > 0 && tuple.importedAt.HasValue) .ToList(); - foreach (var (srcLoc, symLinks, importDate) in vfsLocations) { - result += ResolveManager.GenerateSymbolicLinks(srcLoc, symLinks, importDate!.Value); + foreach (var (symLinks, importDate) in vfsLocations) { + result += ResolveManager.GenerateSymbolicLinks(newSourceLocation, symLinks, importDate!.Value); foreach (var path in symLinks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) topFolders.Add(path); } diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 20c65015..d752307a 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -647,17 +647,26 @@ private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfigur private async Task<LinkGenerationResult> GenerateStructure(CollectionType? collectionType, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) { var result = new LinkGenerationResult(); + var maxTotalExceptions = Plugin.Instance.Configuration.VFS_MaxTotalExceptionsBeforeAbort; + var maxSeriesExceptions = Plugin.Instance.Configuration.VFS_MaxSeriesExceptionsBeforeAbort; + var failedSeries = new HashSet<string>(); + var failedExceptions = new List<Exception>(); + var cancelTokenSource = new CancellationTokenSource(); var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VFS_Threads); await Task.WhenAll(allFiles.Select(async (tuple) => { await semaphore.WaitAsync().ConfigureAwait(false); + var (sourceLocation, fileId, seriesId) = tuple; try { - Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", tuple.sourceLocation, tuple.fileId, tuple.seriesId); + if (cancelTokenSource.IsCancellationRequested) { + Logger.LogTrace("Cancelling generation of links for {Path}", sourceLocation); + return; + } - var (sourceLocation, symbolicLinks, importedAt) = await GenerateLocationsForFile(collectionType, vfsPath, tuple.sourceLocation, tuple.fileId, tuple.seriesId).ConfigureAwait(false); + Logger.LogTrace("Generating links for {Path} (File={FileId},Series={SeriesId})", sourceLocation, fileId, seriesId); - // Skip any source files we weren't meant to have in the library. - if (string.IsNullOrEmpty(sourceLocation) || !importedAt.HasValue) + var (symbolicLinks, importedAt) = await GenerateLocationsForFile(collectionType, vfsPath, sourceLocation, fileId, seriesId).ConfigureAwait(false); + if (symbolicLinks.Length == 0 || !importedAt.HasValue) return; var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, importedAt.Value); @@ -667,20 +676,38 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { result += subResult; } } + catch (Exception ex) { + Logger.LogWarning(ex, "Failed to generate links for {Path} (File={FileId},Series={SeriesId})", sourceLocation, fileId, seriesId); + lock (semaphore) { + failedSeries.Add(seriesId); + failedExceptions.Add(ex); + if ((maxSeriesExceptions > 0 && failedSeries.Count == maxSeriesExceptions) || + (maxTotalExceptions > 0 && failedExceptions.Count == maxTotalExceptions)) { + cancelTokenSource.Cancel(); + } + } + } finally { semaphore.Release(); } })) .ConfigureAwait(false); + // Throw an `AggregateException` if any series exceeded the maximum number of exceptions, or if the total number of exceptions exceeded the maximum allowed. Additionally, + // if no links were generated and there were any exceptions, but we haven't reached the maximum allowed exceptions yet, then also throw an `AggregateException`. + if (cancelTokenSource.IsCancellationRequested || (failedExceptions.Count > 0 && (maxTotalExceptions > 0 || maxSeriesExceptions > 0) && result.TotalVideos == 0)) { + Logger.LogWarning("Failed to generate {FileCount} links across {SeriesCount} series for {Path}", failedExceptions.Count, failedSeries.Count, vfsPath); + throw new AggregateException(failedExceptions); + } + return result; } - public async Task<(string sourceLocation, string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(CollectionType? collectionType, string vfsPath, string sourceLocation, string fileId, string seriesId) + public async Task<(string[] symbolicLinks, DateTime? importedAt)> GenerateLocationsForFile(CollectionType? collectionType, string vfsPath, string sourceLocation, string fileId, string seriesId) { var season = await ApiManager.GetSeasonInfoForSeries(seriesId).ConfigureAwait(false); if (season is null) - return (string.Empty, [], null); + return ([], null); var isMovieSeason = season.Type is SeriesType.Movie; var config = Plugin.Instance.Configuration; @@ -690,19 +717,19 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { _ => false, }; if (shouldAbort) - return (string.Empty, [], null); + return ([], null); var show = await ApiManager.GetShowInfoForSeries(season.Id).ConfigureAwait(false); if (show is null) - return (string.Empty, [], null); + return ([], null); var file = await ApiManager.GetFileInfo(fileId, seriesId).ConfigureAwait(false); var (episode, episodeXref, _) = (file?.EpisodeList ?? []).FirstOrDefault(); if (file is null || episode is null) - return (string.Empty, [], null); + return ([], null); if (season is null || episode is null) - return (string.Empty, [], null); + return ([], null); var showName = show.DefaultSeason.AniDB.Title?.ReplaceInvalidPathCharacters() ?? $"Shoko Series {show.Id}"; var episodeNumber = Ordering.GetEpisodeNumber(show, season, episode); @@ -791,7 +818,7 @@ file.Shoko.AniDBData is not null foreach (var symbolicLink in symbolicLinks) ApiManager.AddFileLookupIds(symbolicLink, fileId, seriesId, file.EpisodeList.Select(episode => episode.Id)); - return (sourceLocation, symbolicLinks, (file.Shoko.ImportedAt ?? file.Shoko.CreatedAt).ToLocalTime()); + return (symbolicLinks, (file.Shoko.ImportedAt ?? file.Shoko.CreatedAt).ToLocalTime()); } public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) From 96518c5c336269f9026a6cca09ec0dac31070778 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Sep 2024 03:41:54 +0200 Subject: [PATCH 087/144] feat: add optional resolve links before vfs option - Added a new advanced option to resolve any symbolic links to their real target before generating the symbolic links to place to the VFS, effectively skipping the additionally needed lookup steps later when Jellyfin accesses the symbolic link placed in the VFS. --- Shokofin/Configuration/PluginConfiguration.cs | 8 ++++++++ Shokofin/Pages/Scripts/Settings.js | 2 ++ Shokofin/Pages/Settings.html | 7 +++++++ Shokofin/Resolvers/VirtualFileSystemService.cs | 14 ++++++++++++++ 4 files changed, 31 insertions(+) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 62881416..dd1633df 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -417,6 +417,13 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool VFS_AttachRoot { get; set; } + /// <summary> + /// If the library contains symbolic links to media, it will follow them + /// until a final "real" file is found and use the path of said file for the + /// VFS + /// </summary> + public bool VFS_ResolveLinks { get; set; } + /// <summary> /// Maximum number of exceptions before aborting the VFS generation. /// </summary> @@ -619,6 +626,7 @@ public PluginConfiguration() VFS_AttachRoot = false; VFS_Location = VirtualRootLocation.Default; VFS_CustomLocation = null; + VFS_ResolveLinks = false; VFS_MaxTotalExceptionsBeforeAbort = 10; VFS_MaxSeriesExceptionsBeforeAbort = 3; AutoMergeVersions = true; diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index 31f603de..b90a342c 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -494,6 +494,7 @@ function applyFormToConfig(form, config) { config.VFS_AddReleaseGroup = form.querySelector("#VFS_AddReleaseGroup").checked; config.VFS_AddResolution = form.querySelector("#VFS_AddResolution").checked; + config.VFS_ResolveLinks = form.querySelector("#VFS_ResolveLinks").checked; config.VFS_AttachRoot = form.querySelector("#VFS_AttachRoot").checked; config.VFS_Location = form.querySelector("#VFS_Location").value; config.VFS_CustomLocation = form.querySelector("#VFS_CustomLocation").value.trim() || null; @@ -717,6 +718,7 @@ async function applyConfigToForm(form, config) { form.querySelector("#VFS_AddReleaseGroup").checked = config.VFS_AddReleaseGroup; form.querySelector("#VFS_AddResolution").checked = config.VFS_AddResolution; + form.querySelector("#VFS_ResolveLinks").checked = config.VFS_ResolveLinks; form.querySelector("#VFS_AttachRoot").checked = config.VFS_AttachRoot; form.querySelector("#VFS_Location").value = config.VFS_Location; form.querySelector("#VFS_CustomLocation").value = config.VFS_CustomLocation || ""; diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 39035829..9f9d4c3f 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -1463,6 +1463,13 @@ <h3>VFS Location Settings</h3> <div class="fieldDescription verticalSection-extrabottompadding"> Configure where to place the VFS in the file system and how to attach it to your libraries. <strong>Tweak at your own risk.</strong> </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="VFS_ResolveLinks" /> + <span>Resolve Links before VFS</span> + </label> + <div class="fieldDescription checkboxFieldDescription">If the library contains symbolic links to media, it will follow them until a final "real" file is found and use the path of said file for the VFS.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_AttachRoot" /> diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index d752307a..4e096fd0 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -825,6 +825,20 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ { try { var result = new LinkGenerationResult(); + if (Plugin.Instance.Configuration.VFS_ResolveLinks) { + Logger.LogTrace("Attempting to resolve link for {Path}", sourceLocation); + try { + if (File.ResolveLinkTarget(sourceLocation, true) is { } linkTarget) { + Logger.LogTrace("Resolved link for {Path} to {LinkTarget}", sourceLocation, linkTarget.FullName); + sourceLocation = linkTarget.FullName; + } + } + catch (Exception ex) { + Logger.LogWarning(ex, "Unable to resolve link target for {Path}", sourceLocation); + return result; + } + } + var sourcePrefixLength = sourceLocation.Length - Path.GetExtension(sourceLocation).Length; var subtitleLinks = FindSubtitlesForPath(sourceLocation); foreach (var symbolicLink in symbolicLinks) { From 1ac96b286f269612880ffc60b02e266530358717 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Sep 2024 03:42:46 +0200 Subject: [PATCH 088/144] chore: singular to plural in method names --- Shokofin/Resolvers/VirtualFileSystemService.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 4e096fd0..971b42ee 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -170,7 +170,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) string? pathToClean = null; IEnumerable<(string sourceLocation, string fileId, string seriesId)>? allFiles = null; if (path.StartsWith(vfsPath + Path.DirectorySeparatorChar)) { - var allPaths = GetPathsForMediaFolder(mediaConfigs); + var allPaths = GetPathsForMediaFolders(mediaConfigs); var pathSegments = path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar); switch (pathSegments.Length) { // show/movie-folder level @@ -244,9 +244,9 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) } // Iterate files in the "real" media folder. else if (mediaConfigs.Any(config => path.StartsWith(config.MediaFolderPath)) || path == vfsPath) { - var allPaths = GetPathsForMediaFolder(mediaConfigs); + var allPaths = GetPathsForMediaFolders(mediaConfigs); pathToClean = vfsPath; - allFiles = GetFilesForImportFolder(mediaConfigs, allPaths); + allFiles = GetFilesForImportFolders(mediaConfigs, allPaths); } if (allFiles is null) @@ -274,7 +274,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) ); } - private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs) + private HashSet<string> GetPathsForMediaFolders(IReadOnlyList<MediaFolderConfiguration> mediaConfigs) { var libraryId = mediaConfigs[0].LibraryId; Logger.LogDebug("Looking for files in library across {Count} folders. (Library={LibraryId})", mediaConfigs.Count, libraryId); @@ -519,7 +519,7 @@ private HashSet<string> GetPathsForMediaFolder(IReadOnlyList<MediaFolderConfigur ); } - private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolder(IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) + private IEnumerable<(string sourceLocation, string fileId, string seriesId)> GetFilesForImportFolders(IReadOnlyList<MediaFolderConfiguration> mediaConfigs, HashSet<string> fileSet) { var start = DateTime.UtcNow; var singleSeriesIds = new HashSet<int>(); From c9a6f75d0dbec8bd9ccf2a12b1d3f5339ab83a53 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Sep 2024 03:43:59 +0200 Subject: [PATCH 089/144] misc: log library id in import folder iteration step during VFS generation --- Shokofin/Resolvers/VirtualFileSystemService.cs | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 971b42ee..b4885efa 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -525,6 +525,7 @@ private HashSet<string> GetPathsForMediaFolders(IReadOnlyList<MediaFolderConfigu var singleSeriesIds = new HashSet<int>(); var multiSeriesFiles = new List<(API.Models.File, string)>(); var totalSingleSeriesFiles = 0; + var libraryId = mediaConfigs[0].LibraryId; foreach (var (importFolderId, importFolderSubPath, mediaFolderPaths) in mediaConfigs.ToImportFolderList()) { var firstPage = ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath); var pageData = firstPage @@ -533,10 +534,11 @@ private HashSet<string> GetPathsForMediaFolders(IReadOnlyList<MediaFolderConfigu .GetResult(); var totalPages = pageData.List.Count == pageData.Total ? 1 : (int)Math.Ceiling((float)pageData.Total / pageData.List.Count); Logger.LogDebug( - "Iterating ≤{FileCount} files to potentially use within media folder at {Path} by checking {TotalCount} matches. (ImportFolder={FolderId},RelativePath={RelativePath},PageSize={PageSize},TotalPages={TotalPages})", + "Iterating ≤{FileCount} files to potentially use within media folder at {Path} by checking {TotalCount} matches. (LibraryId={LibraryId},ImportFolder={FolderId},RelativePath={RelativePath},PageSize={PageSize},TotalPages={TotalPages})", fileSet.Count, mediaFolderPaths, pageData.Total, + libraryId, importFolderId, importFolderSubPath, pageData.List.Count == pageData.Total ? null : pageData.List.Count, @@ -556,9 +558,10 @@ private HashSet<string> GetPathsForMediaFolders(IReadOnlyList<MediaFolderConfigu pageData = task.Result; Logger.LogTrace( - "Iterating page {PageNumber} with size {PageSize} (ImportFolder={FolderId},RelativePath={RelativePath})", + "Iterating page {PageNumber} with size {PageSize} (LibraryId={LibraryId},ImportFolder={FolderId},RelativePath={RelativePath})", totalPages - pages.Count, pageData.List.Count, + libraryId, importFolderId, importFolderSubPath ); @@ -634,7 +637,7 @@ private HashSet<string> GetPathsForMediaFolders(IReadOnlyList<MediaFolderConfigu totalMultiSeriesFiles, mediaConfigs.Count, timeSpent, - mediaConfigs[0].LibraryId + libraryId ); } From 2e842b5cca81ff61a6ae69eb800b9f01ce47418b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Sep 2024 08:40:45 +0200 Subject: [PATCH 090/144] fix: fix paths used in event dispatcher --- Shokofin/Events/EventDispatchService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Events/EventDispatchService.cs b/Shokofin/Events/EventDispatchService.cs index c8164541..d9199f60 100644 --- a/Shokofin/Events/EventDispatchService.cs +++ b/Shokofin/Events/EventDispatchService.cs @@ -308,7 +308,7 @@ private async Task ProcessFileEvents(int fileId, List<(UpdateReason Reason, int foreach (var path in symLinks.Select(path => Path.Join(vfsPath, path[(vfsPath.Length + 1)..].Split(Path.DirectorySeparatorChar).First())).Distinct()) topFolders.Add(path); } - vfsSymbolicLinks = vfsLocations.Select(tuple => tuple.sourceLocation).ToHashSet(); + vfsSymbolicLinks = vfsLocations.SelectMany(tuple => tuple.symbolicLinks).ToHashSet(); } // Remove old links for file. From 2ba25c1a9b469db478e225cda9f1fa92b6051dc8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Sep 2024 12:03:19 +0200 Subject: [PATCH 091/144] fix: workaround for broken `await`/`async` support in Jellyfin Web To-be removed if the issue is fixed in the core, but for now we'll have this ugly workaround. --- Shokofin/Pages/Scripts/Common.js | 50 ++++++++++++++++++++++++++++-- Shokofin/Pages/Scripts/Dummy.js | 19 +++++++++--- Shokofin/Pages/Scripts/Settings.js | 23 ++++++++++---- 3 files changed, 79 insertions(+), 13 deletions(-) diff --git a/Shokofin/Pages/Scripts/Common.js b/Shokofin/Pages/Scripts/Common.js index 56083d82..ace81cd3 100644 --- a/Shokofin/Pages/Scripts/Common.js +++ b/Shokofin/Pages/Scripts/Common.js @@ -737,9 +737,11 @@ const Messages = { * @param {HTMLDivElement} view - The view element. * @param {ViewLifecycleEvents} events - The events. * @param {TabType} [initialTab] - The initial tab. + * @param {boolean} [hide] - Whether to hide the view immediately. + * @param {boolean} [show] - Whether to show the view immediately. * @returns {void} Void. */ -export function setupEvents(view, events, initialTab = "connection") { +export function setupEvents(view, events, initialTab = "connection", hide = false, show = false) { if (events.onBeforeShow) { view.addEventListener("viewbeforeshow", events.onBeforeShow.bind(view)); } @@ -835,6 +837,46 @@ export function setupEvents(view, events, initialTab = "connection") { const initEvent = new CustomEvent("viewinit", { detail: {}, bubbles: true, cancelable: false }); events.onInit.call(view, initEvent); + + // Do nothing if both show and hide are requested. + if (hide && show) return; + + // Show the view if requested. + if (show) { + const eventDetails = { + /** @type {FullDetails} */ + detail: { + type: view.getAttribute("data-type") || null, + params: Object.fromEntries(new URLSearchParams(window.location.hash.split("#").slice(1).join("#").split("?").slice(1).join("?"))), + properties: (view.getAttribute("data-properties") || "").split(","), + isRestored: undefined, + state: null, + options: { + supportsThemeMedia: false, + enableMediaControls: true, + }, + }, + bubbles: true, + cancelable: false, + } + view.dispatchEvent(new CustomEvent("viewbeforeshow", eventDetails)); + view.dispatchEvent(new CustomEvent("viewshow", eventDetails)); + } + + // Hide the view if requested. + if (hide) { + const eventDetails = { + /** @type {MinimalDetails} */ + detail: { + type: event.detail.type, + properties: event.detail.properties, + }, + bubbles: true, + cancelable: false, + }; + view.dispatchEvent(new CustomEvent("viewbeforehide", { ...eventDetails, cancelable: true })); + view.dispatchEvent(new CustomEvent("viewhide", eventDetails)); + } } } @@ -857,6 +899,8 @@ export function setupEvents(view, events, initialTab = "connection") { * @typedef {Object} controllerFactoryOptions * @property {ViewLifecycleEvents} events The lifecycle events for the view. * @property {TabType} [initialTab] - The initial tab. + * @property {boolean} [show] - Whether to show the view immediately. + * @property {boolean} [hide] - Whether to hide the view immediately. */ /** @@ -866,9 +910,9 @@ export function setupEvents(view, events, initialTab = "connection") { * @returns {controllerFactoryFn} The controller factory. */ export function createControllerFactory(options) { - const { events, initialTab } = options; + const { events, initialTab, hide, show } = options; return function(view) { - setupEvents(view, events, initialTab); + setupEvents(view, events, initialTab, hide, show); } } diff --git a/Shokofin/Pages/Scripts/Dummy.js b/Shokofin/Pages/Scripts/Dummy.js index 53daf80a..f08dcff6 100644 --- a/Shokofin/Pages/Scripts/Dummy.js +++ b/Shokofin/Pages/Scripts/Dummy.js @@ -1,3 +1,9 @@ +export default function (view) { +let show = false; +let hide = false; +view.addEventListener("viewshow", () => show = true); +view.addEventListener("viewhide", () => hide = true); + /** * @type {import("./Common.js").ApiClientPrototype} */ @@ -9,11 +15,14 @@ const ApiClient = globalThis.ApiClient; const Dashboard = globalThis.Dashboard; /** - * @type {import("./Common.js")} + * @type {Promise<import("./Common.js")>} */ -const { State, createControllerFactory } = await import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); +const promise = import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); +promise.then(({ State, createControllerFactory }) => { -export default createControllerFactory({ +createControllerFactory({ + show, + hide, initialTab: "utilities", events: { onShow(event) { @@ -35,4 +44,6 @@ export default createControllerFactory({ content.innerHTML = "Dummy."; }, }, -}); +})(view); + +}); } diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index b90a342c..e295e489 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -1,3 +1,9 @@ +export default function (view) { +let show = false; +let hide = false; +view.addEventListener("viewshow", () => show = true); +view.addEventListener("viewhide", () => hide = true); + /** * @type {import("./Common.js").ApiClientPrototype} */ @@ -9,9 +15,10 @@ const ApiClient = globalThis.ApiClient; const Dashboard = globalThis.Dashboard; /** - * @type {import("./Common.js")} + * @type {Promise<import("./Common.js")>} */ -const { +const promise = import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); +promise.then(({ ShokoApiClient, State, createControllerFactory, @@ -22,7 +29,7 @@ const { retrieveCheckboxList, retrieveSortableCheckboxList, updateTabs, -} = await import(ApiClient.getUrl("/web/" + Dashboard.getPluginUrl("Shoko.Common.js"))); +}) => { //#region Constants @@ -72,7 +79,9 @@ const Messages = { //#region Controller Logic -export default createControllerFactory({ +createControllerFactory({ + show, + hide, events: { onInit() { const view = this; @@ -263,7 +272,7 @@ export default createControllerFactory({ applyFormToConfig(form, State.config); }, } -}); +})(view); /** * Update the view to reflect the current state. @@ -1221,4 +1230,6 @@ function filterReconnectIntervals(value) { return Array.from(filteredSet).sort((a, b) => a - b); } -//#endregion \ No newline at end of file +//#endregion + +}); } \ No newline at end of file From c25f4e9cb3cc75fd83dd5fe064f24e292d22f403 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 29 Sep 2024 21:21:38 +0200 Subject: [PATCH 092/144] fix: hide/show container instead of input --- Shokofin/Pages/Scripts/Settings.js | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index e295e489..f8f74efb 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -123,10 +123,10 @@ createControllerFactory({ form.querySelector("#VFS_Location").addEventListener("change", function () { form.querySelector("#VFS_CustomLocation").disabled = this.value !== "Custom"; if (this.value === "Custom") { - form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); + form.querySelector("#VFS_CustomLocationContainer").removeAttribute("hidden"); } else { - form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); + form.querySelector("#VFS_CustomLocationContainer").setAttribute("hidden", ""); } }); @@ -733,10 +733,10 @@ async function applyConfigToForm(form, config) { form.querySelector("#VFS_CustomLocation").value = config.VFS_CustomLocation || ""; form.querySelector("#VFS_CustomLocation").disabled = config.VFS_Location !== "Custom"; if (config.VFS_Location === "Custom") { - form.querySelector("#VFS_CustomLocation").removeAttribute("hidden"); + form.querySelector("#VFS_CustomLocationContainer").removeAttribute("hidden"); } else { - form.querySelector("#VFS_CustomLocation").setAttribute("hidden", ""); + form.querySelector("#VFS_CustomLocationContainer").setAttribute("hidden", ""); } break; } From fd6520f2a859ab60e8058cedd3413ffda356d6a1 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Sep 2024 00:39:16 +0200 Subject: [PATCH 093/144] misc: show library id in export mode --- Shokofin/Pages/Scripts/Settings.js | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index f8f74efb..3ea0ac1b 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -715,7 +715,7 @@ async function applyConfigToForm(form, config) { form.querySelector("#VFS_Enabled").checked = config.VFS_Enabled; form.querySelector("#LibraryFilteringMode").value = config.LibraryFilteringMode; form.querySelector("#MediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + libraries - .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}</option>`) + .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}${config.ExpertMode ? ` (${library.LibraryId})` : ""}</option>`) .join(""); break; } @@ -766,7 +766,7 @@ async function applyConfigToForm(form, config) { form.querySelector("#SignalRDefaultRefreshEvents").checked = config.SignalR_RefreshEnabled; form.querySelector("#SignalRMediaFolderSelector").innerHTML = `<option value="">Click here to select a library</option>` + libraries - .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}</option>`) + .map((library) => `<option value="${library.LibraryId}">${library.LibraryName}${config.ExpertMode ? ` (${library.LibraryId})` : ""}</option>`) .join(""); break; } From 4f49841a77a46081ab3199f6ce59e4b7d970cb69 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Sep 2024 00:39:38 +0200 Subject: [PATCH 094/144] misc: correct config definition in JSDoc --- Shokofin/Pages/Scripts/Settings.js | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index 3ea0ac1b..12afe2f6 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -279,7 +279,7 @@ createControllerFactory({ * * @param {HTMLDivElement} view - The view element. * @param {HTMLFormElement} form - The form element. - * @param {PluginConfiguration} config - The plugin configuration. + * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<void>} */ async function updateView(view, form, config) { @@ -433,7 +433,7 @@ function updateSignalrStatus(form, status) { * Apply a form to a configuration object. * * @param {HTMLFormElement} form - The form element. - * @param {PluginConfiguration} config - The plugin configuration. + * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. */ function applyFormToConfig(form, config) { switch (State.currentTab) { @@ -578,7 +578,7 @@ function applyFormToConfig(form, config) { * Apply the given configuration to the form. * * @param {HTMLFormElement} form - The form element. - * @param {PluginConfiguration} config - The plugin configuration. + * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. */ async function applyConfigToForm(form, config) { switch (State.currentTab) { @@ -793,7 +793,7 @@ async function applyConfigToForm(form, config) { * * @param {HTMLFormElement} form - The form element. * @param {string} userId - The user ID. - * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns */ async function applyUserConfigToForm(form, userId, config = null) { @@ -855,7 +855,7 @@ async function applyUserConfigToForm(form, userId, config = null) { * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. - * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<void>} */ async function applyLibraryConfigToForm(form, libraryId, config = null) { @@ -912,7 +912,7 @@ async function applyLibraryConfigToForm(form, libraryId, config = null) { * * @param {HTMLFormElement} form - The form element. * @param {string} libraryId - The library ID. - * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<void>} */ async function applySignalrLibraryConfigToForm(form, libraryId, config = null) { @@ -1059,7 +1059,7 @@ async function resetConnection(form) { * Synchronize the settings with the server. *1 * @param {HTMLFormElement} form - The form element. - * @param {PluginConfiguration?} config - Optional. The plugin configuration. + * @param {import("./Common.js").PluginConfiguration} config - The plugin configuration. * @returns {Promise<PluginConfiguration>} The updated plugin configuration. */ async function syncSettings(form, config) { From 2290804bd6a9148438707e6be01e2e086712def1 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:52:48 +0200 Subject: [PATCH 095/144] misc: add more logging during library discovery [skip ci] --- .../MediaFolderConfigurationService.cs | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 6bf178fe..6b122413 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -277,11 +277,13 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders Lookup.IsEnabledForLibraryOptions(virtualFolder.LibraryOptions, out _) ) .ToList(); + Logger.LogDebug("Found {Count} out of {TotalCount} libraries to check media folder configurations for." filteredVirtualFolders.Count, allVirtualFolders.Count); var config = Plugin.Instance.Configuration; foreach (var virtualFolder in filteredVirtualFolders) { if (!Guid.TryParse(virtualFolder.ItemId, out var libraryId) || LibraryManager.GetItemById(libraryId) is not Folder libraryFolder) throw new Exception($"Unable to find virtual folder \"{virtualFolder.Name}\""); + Logger.LogDebug("Checking {MediaFolderCount} media folders for library {LibraryName}. (Library={LibraryId)", virtualFolder.Locations, virtualFolder.Name, libraryId); MediaFolderConfiguration? mediaFolderConfig = null; var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); foreach (var mediaFolderPath in virtualFolder.Locations) { @@ -293,6 +295,7 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders if (config.MediaFolders.Find(c => string.Equals(mediaFolderPath, c.MediaFolderPath) && c.LibraryId == libraryId) is { } mfc) { + Logger.LogTrace("Found existing entry for media folder at {Path} (Library={LibraryId})", mediaFolderPath, libraryId); mediaFolderConfig = mfc; continue; } @@ -346,7 +349,7 @@ private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid lib var start = DateTime.UtcNow; var attempts = 0; if (mediaFolder.Path.StartsWith(Plugin.Instance.VirtualRoot)) { - Logger.LogDebug("Not asking remote server because {Path} is a VFS root.", mediaFolder.Path); + Logger.LogDebug("Not asking remote server because {Path} is a VFS root. (Library={LibraryId})", mediaFolder.Path, libraryId); mediaFolderConfig.ImportFolderId = -1; mediaFolderConfig.ImportFolderName = "VFS Root"; mediaFolderConfig.ImportFolderRelativePath = string.Empty; @@ -357,7 +360,7 @@ private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid lib .Take(100) .ToList(); - Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}.", samplePaths.Count > 100 ? 100 : samplePaths.Count, mediaFolder.Path); + Logger.LogDebug("Asking remote server if it knows any of the {Count} sampled files in {Path}. (Library={LibraryId})", samplePaths.Count > 100 ? 100 : samplePaths.Count, mediaFolder.Path, libraryId); foreach (var path in samplePaths) { attempts++; var partialPath = path[mediaFolder.Path.Length..]; @@ -393,21 +396,23 @@ private async Task<MediaFolderConfiguration> CreateConfigurationForPath(Guid lib Plugin.Instance.UpdateConfiguration(config); if (mediaFolderConfig.IsMapped) { Logger.LogInformation( - "Found a match for media folder at {Path} in {TimeSpan} (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts})", + "Found a match for media folder at {Path} in {TimeSpan}. (ImportFolder={FolderId},RelativePath={RelativePath},MediaLibrary={Path},Attempts={Attempts},Library={LibraryId})", mediaFolder.Path, DateTime.UtcNow - start, mediaFolderConfig.ImportFolderId, mediaFolderConfig.ImportFolderRelativePath, mediaFolder.Path, - attempts + attempts, + libraryId ); } else { Logger.LogWarning( - "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}.", + "Failed to find a match for media folder at {Path} after {Amount} attempts in {TimeSpan}. (Library={LibraryId})", mediaFolder.Path, attempts, - DateTime.UtcNow - start + DateTime.UtcNow - start, + libraryId ); } From 5e10d546fd9ec782a04a8af3ed5fcbd5be72f7d5 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:54:08 +0200 Subject: [PATCH 096/144] fix: always re-evaluate libraries after a library scan or stalled event --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 6b122413..fe902fe6 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -129,10 +129,11 @@ private void OnUsageTrackerStalled(object? sender, EventArgs eventArgs) private void EditLibraries(bool shouldScheduleLibraryScan) { lock (LockObj) { + ShouldGenerateAllConfigurations = true; + if (LibraryEdits.Count is 0) return; - ShouldGenerateAllConfigurations = true; var libraryEdits = LibraryEdits.ToList(); LibraryEdits.Clear(); foreach (var (libraryId, (libraryName, add, remove)) in libraryEdits) { From ca68c22602bfe1f407d7900d51973d3ee44e3bb1 Mon Sep 17 00:00:00 2001 From: "Mikal S." <7761729+revam@users.noreply.github.com> Date: Mon, 30 Sep 2024 14:58:22 +0200 Subject: [PATCH 097/144] misc: add missing comma Because I wrote the last two commits blind on mobile (so no LSP to yell at me). --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index fe902fe6..79156763 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -278,7 +278,7 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders Lookup.IsEnabledForLibraryOptions(virtualFolder.LibraryOptions, out _) ) .ToList(); - Logger.LogDebug("Found {Count} out of {TotalCount} libraries to check media folder configurations for." filteredVirtualFolders.Count, allVirtualFolders.Count); + Logger.LogDebug("Found {Count} out of {TotalCount} libraries to check media folder configurations for.", filteredVirtualFolders.Count, allVirtualFolders.Count); var config = Plugin.Instance.Configuration; foreach (var virtualFolder in filteredVirtualFolders) { if (!Guid.TryParse(virtualFolder.ItemId, out var libraryId) || LibraryManager.GetItemById(libraryId) is not Folder libraryFolder) From 8b9c5cb3289412c98ce0a54f9854cbc86ddf18b8 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 30 Sep 2024 23:01:12 +0200 Subject: [PATCH 098/144] =?UTF-8?q?fix:=20fix=20faulty=20log=20statement?= =?UTF-8?q?=20Again,=20i=20wrote=20it=20blind=E2=80=A6=20and=20now=20that?= =?UTF-8?q?=20I=20have=20my=20filtered=20glasses=20(read=20as;=20syntax=20?= =?UTF-8?q?highlighting=20and=20an=20LSP=20server=20running)=20on=E2=80=A6?= =?UTF-8?q?=20then=20I=20see=20I=20forgot=20to=20get=20the=20length=20the?= =?UTF-8?q?=20array=20in=20the=20log=20point.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 79156763..8d7d850b 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -284,7 +284,7 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders if (!Guid.TryParse(virtualFolder.ItemId, out var libraryId) || LibraryManager.GetItemById(libraryId) is not Folder libraryFolder) throw new Exception($"Unable to find virtual folder \"{virtualFolder.Name}\""); - Logger.LogDebug("Checking {MediaFolderCount} media folders for library {LibraryName}. (Library={LibraryId)", virtualFolder.Locations, virtualFolder.Name, libraryId); + Logger.LogDebug("Checking {MediaFolderCount} media folders for library {LibraryName}. (Library={LibraryId})", virtualFolder.Locations.Length, virtualFolder.Name, libraryId); MediaFolderConfiguration? mediaFolderConfig = null; var libraryConfig = config.MediaFolders.FirstOrDefault(c => c.LibraryId == libraryId); foreach (var mediaFolderPath in virtualFolder.Locations) { From 669503f688debe1c6727d3a4903d0b6255cfec95 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 1 Oct 2024 01:24:18 +0200 Subject: [PATCH 099/144] fix: no configure await false on get awaiter inside lock --- .../MediaFolderConfigurationService.cs | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 8d7d850b..f30c46ca 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -258,7 +258,12 @@ public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder me if (allVirtualFolders.FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path) && (collectionType is CollectionType.unknown || p.CollectionType.ConvertToCollectionType() == collectionType)) is not { } library || !Guid.TryParse(library.ItemId, out var libraryId)) throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); - GenerateAllConfigurations(allVirtualFolders); + + if (ShouldGenerateAllConfigurations) + { + ShouldGenerateAllConfigurations = false; + GenerateAllConfigurations(allVirtualFolders).GetAwaiter().GetResult(); + } var config = Plugin.Instance.Configuration; var mediaFolderConfig = config.MediaFolders.First(c => c.MediaFolderId == mediaFolder.Id && c.LibraryId == libraryId); @@ -266,12 +271,8 @@ public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder me } } - private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders) + private async Task GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders) { - if (!ShouldGenerateAllConfigurations) - return; - ShouldGenerateAllConfigurations = false; - var filteredVirtualFolders = allVirtualFolders .Where(virtualFolder => virtualFolder.CollectionType.ConvertToCollectionType() is null or CollectionType.movies or CollectionType.tvshows && @@ -301,7 +302,7 @@ private void GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders continue; } - mediaFolderConfig = CreateConfigurationForPath(libraryId, secondFolder, libraryConfig).ConfigureAwait(false).GetAwaiter().GetResult(); + mediaFolderConfig = await CreateConfigurationForPath(libraryId, secondFolder, libraryConfig).ConfigureAwait(false); } if (mediaFolderConfig is null) From 1f739a6559a75a29f8105dbb14749b3722703448 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Tue, 1 Oct 2024 01:28:53 +0200 Subject: [PATCH 100/144] fix: properly update view after removing library config --- Shokofin/Pages/Scripts/Settings.js | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index 12afe2f6..e77f17b5 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -358,12 +358,12 @@ async function updateView(view, form, config) { case "library": activeSections.push("Library_Basic", "Library_Collection", "Library_New", "Library_Existing", "Library_Experimental"); + + await applyLibraryConfigToForm(form, form.querySelector("#MediaFolderSelector").value, config); break; case "vfs": activeSections.push("VFS_Basic", "VFS_Location"); - - await applyLibraryConfigToForm(form, form.querySelector("#MediaFolderSelector").value, config); break; case "users": @@ -1123,6 +1123,8 @@ async function removeUserConfig(form) { await ShokoApiClient.updateConfiguration(config); Dashboard.processPluginConfigurationUpdateResult(); + form.querySelector("#UserSelector").value = ""; + return config; } From 9c772da68ad8621fd5338e52f12c64d5a291d9ff Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 00:42:42 +0200 Subject: [PATCH 101/144] feat: add shoko preferred descriptions --- Shokofin/API/Models/Episode.cs | 10 +++++++ Shokofin/API/Models/Series.cs | 10 +++++++ Shokofin/Configuration/PluginConfiguration.cs | 6 ++-- Shokofin/Pages/Settings.html | 16 +++++------ Shokofin/Providers/BoxSetProvider.cs | 2 +- Shokofin/Providers/EpisodeProvider.cs | 4 +-- Shokofin/Providers/MovieProvider.cs | 4 +-- Shokofin/Providers/SeasonProvider.cs | 4 +-- Shokofin/Providers/SeriesProvider.cs | 2 +- Shokofin/Providers/TrailerProvider.cs | 2 +- Shokofin/Providers/VideoProvider.cs | 2 +- Shokofin/Utils/Text.cs | 28 +++++++++++++------ 12 files changed, 60 insertions(+), 30 deletions(-) diff --git a/Shokofin/API/Models/Episode.cs b/Shokofin/API/Models/Episode.cs index c37f4c51..87d26ace 100644 --- a/Shokofin/API/Models/Episode.cs +++ b/Shokofin/API/Models/Episode.cs @@ -12,8 +12,18 @@ public class Episode /// </summary> public EpisodeIDs IDs { get; set; } = new(); + /// <summary> + /// The preferred name of the episode based on the selected episode language + /// settings on the server. + /// </summary> public string Name { get; set; } = string.Empty; + /// <summary> + /// The preferred description of the episode based on the selected episode + /// language settings on the server. + /// </summary> + public string Description { get; set; } = string.Empty; + /// <summary> /// The duration of the episode. /// </summary> diff --git a/Shokofin/API/Models/Series.cs b/Shokofin/API/Models/Series.cs index 91ded7d2..3d949aa8 100644 --- a/Shokofin/API/Models/Series.cs +++ b/Shokofin/API/Models/Series.cs @@ -6,8 +6,18 @@ namespace Shokofin.API.Models; public class Series { + /// <summary> + /// The preferred name of the series based on the selected series language + /// settings on the server. + /// </summary> public string Name { get; set; } = string.Empty; + /// <summary> + /// The preferred description of the series based on the selected series + /// language settings on the server. + /// </summary> + public string Description { get; set; } = string.Empty; + public int Size { get; set; } /// <summary> diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index dd1633df..04f01c33 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -614,11 +614,13 @@ public PluginConfiguration() DescriptionSourceOverride = false; DescriptionSourceList = [ DescriptionProvider.Shoko, + ]; + DescriptionSourceOrder = [ + DescriptionProvider.Shoko, DescriptionProvider.AniDB, - DescriptionProvider.TvDB, DescriptionProvider.TMDB, + DescriptionProvider.TvDB, ]; - DescriptionSourceOrder = [.. DescriptionSourceList]; VFS_Enabled = true; VFS_Threads = 4; VFS_AddReleaseGroup = false; diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 9f9d4c3f..12d576d7 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -294,7 +294,7 @@ <h3 class="checkboxListLabel">Advanced description source:</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Shoko</h3> + <h3 class="listItemBodyText">Shoko | Let Shoko decide</h3> <span></span> </div> <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> @@ -307,32 +307,32 @@ <h3 class="listItemBodyText">Shoko</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB</h3> + <h3 class="listItemBodyText">AniDB | Follow metadata language in library</h3> <span></span> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> </button> </div> - <div class="listItem sortableOption" data-option="TvDB"> + <div class="listItem sortableOption" data-option="TMDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="TvDB"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TvDB</h3> + <h3 class="listItemBodyText">TMDB | Follow metadata language in library</h3> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> </button> </div> - <div class="listItem sortableOption" data-option="TMDB"> + <div class="listItem sortableOption" data-option="TvDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <input is="emby-checkbox" type="checkbox" data-option="TvDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TMDB</h3> + <h3 class="listItemBodyText">TvDB | Follow metadata language in library</h3> </div> <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> diff --git a/Shokofin/Providers/BoxSetProvider.cs b/Shokofin/Providers/BoxSetProvider.cs index 381443af..845331b5 100644 --- a/Shokofin/Providers/BoxSetProvider.cs +++ b/Shokofin/Providers/BoxSetProvider.cs @@ -72,7 +72,7 @@ private async Task<MetadataResult<BoxSet>> GetShokoSeriesMetadata(BoxSetInfo inf result.Item = new BoxSet { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.GetDescription(season), + Overview = Text.GetDescription(season, info.MetadataLanguage), PremiereDate = season.AniDB.AirDate, EndDate = season.AniDB.EndDate, ProductionYear = season.AniDB.AirDate?.Year, diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index 67779204..b556881b 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -136,7 +136,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie } displayTitle = Text.JoinText(displayTitles); alternateTitle = Text.JoinText(alternateTitles); - description = Text.GetDescription(file.EpisodeList.Select(tuple => tuple.Episode)); + description = Text.GetDescription(file.EpisodeList.Select(tuple => tuple.Episode), metadataLanguage); } else { string defaultEpisodeTitle = episode.Shoko.Name; @@ -152,7 +152,7 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie else { (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episode, series, metadataLanguage); } - description = Text.GetDescription(episode); + description = Text.GetDescription(episode, metadataLanguage); } if (config.MarkSpecialsWhenGrouped) switch (episode.AniDB.Type) { diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index 03a5213e..a8895aa0 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -52,15 +52,13 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio Logger.LogInformation("Found movie {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds})", displayTitle, file.Id, episode.Id, season.Id, season.ExtraIds); bool isMultiEntry = season.Shoko.Sizes.Total.Episodes > 1; - bool isMainEntry = episode.AniDB.Type == API.Models.EpisodeType.Normal && episode.Shoko.Name.Trim() == "Complete Movie"; var rating = isMultiEntry ? episode.AniDB.Rating.ToFloat(10) : season.AniDB.Rating.ToFloat(10); result.Item = new Movie { Name = displayTitle, OriginalTitle = alternateTitle, PremiereDate = episode.AniDB.AirDate, - // Use the file description if collection contains more than one movie and the file is not the main entry, otherwise use the collection description. - Overview = isMultiEntry && !isMainEntry ? Text.GetDescription(episode) : Text.GetDescription(season), + Overview = Text.GetMovieDescription(episode, season, info.MetadataLanguage), ProductionYear = episode.AniDB.AirDate?.Year, Tags = season.Tags.ToArray(), Genres = season.Genres.ToArray(), diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index a16e55f8..64e01b42 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -119,7 +119,7 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber ForcedSortName = sortTitle, Id = seasonId, IsVirtualItem = true, - Overview = Text.GetDescription(seasonInfo), + Overview = Text.GetDescription(seasonInfo, metadataLanguage), PremiereDate = seasonInfo.AniDB.AirDate, EndDate = seasonInfo.AniDB.EndDate, ProductionYear = seasonInfo.AniDB.AirDate?.Year, @@ -143,7 +143,7 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber IndexNumber = seasonNumber, SortName = sortTitle, ForcedSortName = sortTitle, - Overview = Text.GetDescription(seasonInfo), + Overview = Text.GetDescription(seasonInfo, metadataLanguage), PremiereDate = seasonInfo.AniDB.AirDate, EndDate = seasonInfo.AniDB.EndDate, ProductionYear = seasonInfo.AniDB.AirDate?.Year, diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index ef468ae5..ae088302 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -70,7 +70,7 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat result.Item = new Series { Name = displayTitle, OriginalTitle = alternateTitle, - Overview = Text.GetDescription(show), + Overview = Text.GetDescription(show, info.MetadataLanguage), PremiereDate = premiereDate, ProductionYear = premiereDate?.Year, EndDate = endDate, diff --git a/Shokofin/Providers/TrailerProvider.cs b/Shokofin/Providers/TrailerProvider.cs index 14de6862..1a988c79 100644 --- a/Shokofin/Providers/TrailerProvider.cs +++ b/Shokofin/Providers/TrailerProvider.cs @@ -51,7 +51,7 @@ public async Task<MetadataResult<Trailer>> GetMetadata(TrailerInfo info, Cancell } var (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); - var description = Text.GetDescription(episodeInfo); + var description = Text.GetDescription(episodeInfo, info.MetadataLanguage); result.Item = new() { Name = displayTitle, diff --git a/Shokofin/Providers/VideoProvider.cs b/Shokofin/Providers/VideoProvider.cs index 3581d970..2fe678ae 100644 --- a/Shokofin/Providers/VideoProvider.cs +++ b/Shokofin/Providers/VideoProvider.cs @@ -51,7 +51,7 @@ public async Task<MetadataResult<Video>> GetMetadata(ItemLookupInfo info, Cancel } var (displayTitle, alternateTitle) = Text.GetEpisodeTitles(episodeInfo, seasonInfo, info.MetadataLanguage); - var description = Text.GetDescription(episodeInfo); + var description = Text.GetDescription(episodeInfo, info.MetadataLanguage); result.Item = new() { Name = displayTitle, diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index e1ca161b..8b4c6f17 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -153,27 +153,37 @@ public enum TitleProviderType { Alternate = 1, } - public static string GetDescription(ShowInfo show) + public static string GetDescription(ShowInfo show, string? metadataLanguage) => GetDescriptionByDict(new() { - {DescriptionProvider.Shoko, show.Shoko?.Description}, - {DescriptionProvider.AniDB, show.DefaultSeason.AniDB.Description}, + {DescriptionProvider.Shoko, show.Shoko?.Description ?? show.DefaultSeason.Shoko.Description}, + {DescriptionProvider.AniDB, metadataLanguage is "en" ? show.DefaultSeason.AniDB.Description : null}, {DescriptionProvider.TvDB, show.DefaultSeason.TvDB?.Description}, }); - public static string GetDescription(SeasonInfo season) + public static string GetDescription(SeasonInfo season, string? metadataLanguage) => GetDescriptionByDict(new() { - {DescriptionProvider.AniDB, season.AniDB.Description}, + {DescriptionProvider.Shoko, season.Shoko.Description}, + {DescriptionProvider.AniDB, metadataLanguage is "en" ? season.AniDB.Description : null}, {DescriptionProvider.TvDB, season.TvDB?.Description}, }); - public static string GetDescription(EpisodeInfo episode) + public static string GetDescription(EpisodeInfo episode, string? metadataLanguage) => GetDescriptionByDict(new() { - {DescriptionProvider.AniDB, episode.AniDB.Description}, + {DescriptionProvider.Shoko, episode.Shoko.Description}, + {DescriptionProvider.AniDB, metadataLanguage is "en" ? episode.AniDB.Description : null}, {DescriptionProvider.TvDB, episode.TvDB?.Description}, }); - public static string GetDescription(IEnumerable<EpisodeInfo> episodeList) - => JoinText(episodeList.Select(episode => GetDescription(episode))) ?? string.Empty; + public static string GetDescription(IEnumerable<EpisodeInfo> episodeList, string? metadataLanguage) + => JoinText(episodeList.Select(episode => GetDescription(episode, metadataLanguage))) ?? string.Empty; + + public static string GetMovieDescription(EpisodeInfo episode, SeasonInfo season, string? metadataLanguage) + { + // TODO: Actually implement actual movie descriptions from TMDB once it's made available in the plugin. + bool isMultiEntry = season.Shoko.Sizes.Total.Episodes > 1; + bool isMainEntry = episode.AniDB.Type == API.Models.EpisodeType.Normal && episode.Shoko.Name.Trim() == "Complete Movie"; + return isMultiEntry && !isMainEntry ? GetDescription(episode, metadataLanguage) : GetDescription(season, metadataLanguage); + } /// <summary> /// Returns a list of the description providers to check, and in what order From 716296990703904887d918b7b31e0445b4365e4d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 01:03:44 +0200 Subject: [PATCH 102/144] misc: pass country code to content rating util + more - Pass the metadata country code from the library along to the content rating utility to-be used to figure out the TMDB content rating to use for the library, but since the TMDB data won't be available until _after_ the next stable release then the rest will wait. Expect a follow up commit hooking up the rest soon-ish. - Fixed up the misleading names of the production location methods and simplify their return value type so we can just pass it along instead of doing another conversion on it before using it. --- Shokofin/Providers/EpisodeProvider.cs | 17 +++++++++-------- Shokofin/Providers/MovieProvider.cs | 5 ++--- Shokofin/Providers/SeasonProvider.cs | 18 +++++++++--------- Shokofin/Providers/SeriesProvider.cs | 4 ++-- Shokofin/Utils/ContentRating.cs | 10 ++++++---- Shokofin/Utils/TagFilter.cs | 26 +++++++++++++------------- 6 files changed, 41 insertions(+), 39 deletions(-) diff --git a/Shokofin/Providers/EpisodeProvider.cs b/Shokofin/Providers/EpisodeProvider.cs index b556881b..8b9f1177 100644 --- a/Shokofin/Providers/EpisodeProvider.cs +++ b/Shokofin/Providers/EpisodeProvider.cs @@ -78,7 +78,7 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell return result; } - result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage); + result.Item = CreateMetadata(showInfo, seasonInfo, episodeInfo, fileInfo, info.MetadataLanguage, info.MetadataCountryCode); Logger.LogInformation("Found episode {EpisodeName} (File={FileId},Episode={EpisodeId},Series={SeriesId},ExtraSeries={ExtraIds},Group={GroupId})", result.Item.Name, fileInfo?.Id, episodeInfo.Id, seasonInfo.Id, seasonInfo.ExtraIds, showInfo?.GroupId); result.HasMetadata = true; @@ -103,12 +103,12 @@ public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, Cancell } public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Season season, Guid episodeId) - => CreateMetadata(group, series, episode, null, season.GetPreferredMetadataLanguage(), season, episodeId); + => CreateMetadata(group, series, episode, null, season.GetPreferredMetadataLanguage(), season.GetPreferredMetadataCountryCode(), season, episodeId); - public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage) - => CreateMetadata(group, series, episode, file, metadataLanguage, null, Guid.Empty); + public static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage, string metadataCountryCode) + => CreateMetadata(group, series, episode, file, metadataLanguage, metadataCountryCode, null, Guid.Empty); - private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage, Season? season, Guid episodeId) + private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo series, Info.EpisodeInfo episode, Info.FileInfo? file, string metadataLanguage, string metadataCountryCode, Season? season, Guid episodeId) { var config = Plugin.Instance.Configuration; string? displayTitle, alternateTitle, description; @@ -210,8 +210,8 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie SeriesName = season.Series.Name, SeriesPresentationUniqueKey = season.SeriesPresentationUniqueKey, SeasonName = season.Name, - ProductionLocations = TagFilter.GetSeasonContentRating(series).ToArray(), - OfficialRating = ContentRating.GetSeasonContentRating(series), + ProductionLocations = TagFilter.GetSeasonProductionLocations(series), + OfficialRating = ContentRating.GetSeasonContentRating(series, metadataCountryCode), DateLastSaved = DateTime.UtcNow, RunTimeTicks = episode.AniDB.Duration.Ticks, }; @@ -228,7 +228,8 @@ private static Episode CreateMetadata(Info.ShowInfo group, Info.SeasonInfo serie AirsBeforeSeasonNumber = airsBeforeSeasonNumber, PremiereDate = episode.AniDB.AirDate, Overview = description, - OfficialRating = ContentRating.GetSeasonContentRating(series), + ProductionLocations = TagFilter.GetSeasonProductionLocations(series), + OfficialRating = ContentRating.GetSeasonContentRating(series, metadataCountryCode), CustomRating = group.CustomRating, CommunityRating = episode.AniDB.Rating.Value > 0 ? episode.AniDB.Rating.ToFloat(10) : 0, }; diff --git a/Shokofin/Providers/MovieProvider.cs b/Shokofin/Providers/MovieProvider.cs index a8895aa0..9d2c8ca3 100644 --- a/Shokofin/Providers/MovieProvider.cs +++ b/Shokofin/Providers/MovieProvider.cs @@ -63,10 +63,9 @@ public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, Cancellatio Tags = season.Tags.ToArray(), Genres = season.Genres.ToArray(), Studios = season.Studios.ToArray(), - ProductionLocations = TagFilter.GetMovieContentRating(season, episode).ToArray(), - OfficialRating = ContentRating.GetMovieContentRating(season, episode), + ProductionLocations = TagFilter.GetMovieProductionLocations(season, episode), + OfficialRating = ContentRating.GetMovieContentRating(season, episode, info.MetadataCountryCode), CommunityRating = rating, - DateCreated = file.Shoko.ImportedAt ?? file.Shoko.CreatedAt, }; result.Item.SetProviderId(ShokoFileId.Name, file.Id); result.Item.SetProviderId(ShokoEpisodeId.Name, episode.Id); diff --git a/Shokofin/Providers/SeasonProvider.cs b/Shokofin/Providers/SeasonProvider.cs index 64e01b42..80231c5e 100644 --- a/Shokofin/Providers/SeasonProvider.cs +++ b/Shokofin/Providers/SeasonProvider.cs @@ -82,7 +82,7 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat var offset = Math.Abs(seasonNumber - baseSeasonNumber); - result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage); + result.Item = CreateMetadata(seasonInfo, seasonNumber, offset, info.MetadataLanguage, info.MetadataCountryCode); result.HasMetadata = true; result.ResetPeople(); foreach (var person in seasonInfo.Staff) @@ -99,13 +99,13 @@ public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, Cancellat } } - public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage) - => CreateMetadata(seasonInfo, seasonNumber, offset, metadataLanguage, null, Guid.Empty); + public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, string metadataCountryCode) + => CreateMetadata(seasonInfo, seasonNumber, offset, metadataLanguage, metadataCountryCode, null, Guid.Empty); public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, Series series, Guid seasonId) - => CreateMetadata(seasonInfo, seasonNumber, offset, series.GetPreferredMetadataLanguage(), series, seasonId); + => CreateMetadata(seasonInfo, seasonNumber, offset, series.GetPreferredMetadataLanguage(), series.GetPreferredMetadataCountryCode(), series, seasonId); - public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, Series? series, Guid seasonId) + private static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber, int offset, string metadataLanguage, string metadataCountryCode, Series? series, Guid seasonId) { var (displayTitle, alternateTitle) = Text.GetSeasonTitles(seasonInfo, offset, metadataLanguage); var sortTitle = $"S{seasonNumber} - {seasonInfo.Shoko.Name}"; @@ -126,8 +126,8 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber Tags = seasonInfo.Tags.ToArray(), Genres = seasonInfo.Genres.ToArray(), Studios = seasonInfo.Studios.ToArray(), - ProductionLocations = TagFilter.GetSeasonContentRating(seasonInfo).ToArray(), - OfficialRating = ContentRating.GetSeasonContentRating(seasonInfo), + ProductionLocations = TagFilter.GetSeasonProductionLocations(seasonInfo), + OfficialRating = ContentRating.GetSeasonContentRating(seasonInfo, metadataCountryCode), CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), SeriesId = series.Id, SeriesName = series.Name, @@ -150,8 +150,8 @@ public static Season CreateMetadata(Info.SeasonInfo seasonInfo, int seasonNumber Tags = seasonInfo.Tags.ToArray(), Genres = seasonInfo.Genres.ToArray(), Studios = seasonInfo.Studios.ToArray(), - ProductionLocations = TagFilter.GetSeasonContentRating(seasonInfo).ToArray(), - OfficialRating = ContentRating.GetSeasonContentRating(seasonInfo), + ProductionLocations = TagFilter.GetSeasonProductionLocations(seasonInfo), + OfficialRating = ContentRating.GetSeasonContentRating(seasonInfo, metadataCountryCode), CommunityRating = seasonInfo.AniDB.Rating?.ToFloat(10), }; } diff --git a/Shokofin/Providers/SeriesProvider.cs b/Shokofin/Providers/SeriesProvider.cs index ae088302..fa1da45b 100644 --- a/Shokofin/Providers/SeriesProvider.cs +++ b/Shokofin/Providers/SeriesProvider.cs @@ -78,8 +78,8 @@ public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, Cancellat Tags = show.Tags.ToArray(), Genres = show.Genres.ToArray(), Studios = show.Studios.ToArray(), - ProductionLocations = TagFilter.GetShowContentRating(show).ToArray(), - OfficialRating = ContentRating.GetShowContentRating(show), + ProductionLocations = TagFilter.GetShowProductionLocations(show), + OfficialRating = ContentRating.GetShowContentRating(show, info.MetadataCountryCode), CustomRating = show.CustomRating, CommunityRating = show.CommunityRating, }; diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs index dbc74572..762c813b 100644 --- a/Shokofin/Utils/ContentRating.cs +++ b/Shokofin/Utils/ContentRating.cs @@ -159,7 +159,7 @@ private static ProviderName[] GetOrderedProviders() : [ProviderName.AniDB, ProviderName.TMDB]; #pragma warning disable IDE0060 - public static string? GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) + public static string? GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo, string? metadataCountryCode) #pragma warning restore IDE0060 { // TODO: Add TMDB movie linked to episode content rating here. @@ -175,7 +175,9 @@ private static ProviderName[] GetOrderedProviders() return null; } - public static string? GetSeasonContentRating(SeasonInfo seasonInfo) +#pragma warning disable IDE0060 + public static string? GetSeasonContentRating(SeasonInfo seasonInfo, string? metadataCountryCode) +#pragma warning restore IDE0060 { foreach (var provider in GetOrderedProviders()) { var title = provider switch { @@ -189,10 +191,10 @@ private static ProviderName[] GetOrderedProviders() return null; } - public static string? GetShowContentRating(ShowInfo showInfo) + public static string? GetShowContentRating(ShowInfo showInfo, string? metadataCountryCode) { var (contentRating, contentIndicators) = showInfo.SeasonOrderDictionary.Values - .Select(seasonInfo => GetSeasonContentRating(seasonInfo)) + .Select(seasonInfo => GetSeasonContentRating(seasonInfo, metadataCountryCode)) .Where(contentRating => !string.IsNullOrEmpty(contentRating)) .Distinct() .Select(text => TryConvertRatingFromText(text, out var cR, out var cI) ? (contentRating: cR, contentIndicators: cI ?? []) : (contentRating: TvRating.None, contentIndicators: [])) diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs index d69f5991..43b7ce28 100644 --- a/Shokofin/Utils/TagFilter.cs +++ b/Shokofin/Utils/TagFilter.cs @@ -324,45 +324,45 @@ private static ProviderName[] GetOrderedProductionLocationProviders() : [ProviderName.AniDB, ProviderName.TMDB]; #pragma warning disable IDE0060 - public static IReadOnlyList<string> GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) + public static string[] GetMovieProductionLocations(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) #pragma warning restore IDE0060 { // TODO: Add TMDB movie linked to episode content rating here. foreach (var provider in GetOrderedProductionLocationProviders()) { - var title = provider switch { - ProviderName.AniDB => seasonInfo.ProductionLocations, + var locations = provider switch { + ProviderName.AniDB => seasonInfo.ProductionLocations.ToArray(), // TODO: Add TMDB series content rating here. _ => [], }; - if (title.Count > 0) - return title; + if (locations.Length > 0) + return locations; } return []; } - public static IReadOnlyList<string> GetSeasonContentRating(SeasonInfo seasonInfo) + public static string[] GetSeasonProductionLocations(SeasonInfo seasonInfo) { foreach (var provider in GetOrderedProductionLocationProviders()) { - var title = provider switch { - ProviderName.AniDB => seasonInfo.ProductionLocations, + var locations = provider switch { + ProviderName.AniDB => seasonInfo.ProductionLocations.ToArray(), // TODO: Add TMDB series content rating here. _ => [], }; - if (title.Count > 0) - return title; + if (locations.Length > 0) + return locations; } return []; } - public static IReadOnlyList<string> GetShowContentRating(ShowInfo showInfo) + public static string[] GetShowProductionLocations(ShowInfo showInfo) { foreach (var provider in GetOrderedProductionLocationProviders()) { var title = provider switch { - ProviderName.AniDB => showInfo.ProductionLocations, + ProviderName.AniDB => showInfo.ProductionLocations.ToArray(), // TODO: Add TMDB series content rating here. _ => [], }; - if (title.Count > 0) + if (title.Length > 0) return title; } return []; From aeb94b58dfe75f32afb94fc3f92818931d91cf70 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 01:04:10 +0200 Subject: [PATCH 103/144] misc: remove unused logger - Remove the unused logger from the custom movie provider. --- Shokofin/Providers/CustomMovieProvider.cs | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/Shokofin/Providers/CustomMovieProvider.cs b/Shokofin/Providers/CustomMovieProvider.cs index 8f02d4c5..15015764 100644 --- a/Shokofin/Providers/CustomMovieProvider.cs +++ b/Shokofin/Providers/CustomMovieProvider.cs @@ -1,11 +1,9 @@ -using System.Linq; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; -using Microsoft.Extensions.Logging; using Shokofin.ExternalIds; using Shokofin.MergeVersions; @@ -23,15 +21,12 @@ public class CustomMovieProvider : ICustomMetadataProvider<Movie> { public string Name => Plugin.MetadataProviderName; - private readonly ILogger<CustomEpisodeProvider> _logger; - private readonly ILibraryManager _libraryManager; private readonly MergeVersionsManager _mergeVersionsManager; - public CustomMovieProvider(ILogger<CustomEpisodeProvider> logger, ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) + public CustomMovieProvider(ILibraryManager libraryManager, MergeVersionsManager mergeVersionsManager) { - _logger = logger; _libraryManager = libraryManager; _mergeVersionsManager = mergeVersionsManager; } From 4652327d2c8f6a8cfabf9c82ece0b3648639b020 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 01:05:45 +0200 Subject: [PATCH 104/144] misc: visually correct 'expert mode' to 'advanced mode' in the UI - Visually correct what I personally refer to as 'expert mode' to 'advanced mode' to be consistent with the upcoming documentation. ~~It will still be referred to as 'expert mode' in the code.~~ --- Shokofin/Pages/Scripts/Settings.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index e77f17b5..21b473ca 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -66,9 +66,9 @@ const Sections = [ ]; const Messages = { - ExpertModeCountdown: "Press <count> more times to <toggle> expert mode.", - ExpertModeEnabled: "Expert mode enabled.", - ExpertModeDisabled: "Expert mode disabled.", + ExpertModeCountdown: "Press <count> more times to <toggle> advanced mode.", + ExpertModeEnabled: "Advanced mode enabled.", + ExpertModeDisabled: "Advanced mode disabled.", ConnectToShoko: "Please establish a connection to a running instance of Shoko Server before you continue.", ConnectedToShoko: "Connection established.", DisconnectedToShoko: "Connection has been reset.", From ecd139800e60b8abb2d22316ca794e330ee643a0 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 01:17:14 +0200 Subject: [PATCH 105/144] refactor: remove `*Override` settings - Remove all `*Override` settings now that the expert/advanced mode exists. --- Shokofin/Configuration/PluginConfiguration.cs | 105 +++++--------- Shokofin/Pages/Scripts/Settings.js | 133 ------------------ Shokofin/Pages/Settings.html | 121 ++++------------ Shokofin/Utils/ContentRating.cs | 4 +- Shokofin/Utils/TagFilter.cs | 25 +--- Shokofin/Utils/Text.cs | 12 +- 6 files changed, 66 insertions(+), 334 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 04f01c33..049f5c4e 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -122,11 +122,6 @@ public DescriptionProvider[] ThirdPartyIdProviderList #region Metadata - /// <summary> - /// Determines if we use the overridden settings for how the main title is fetched for entries. - /// </summary> - public bool TitleMainOverride { get; set; } - /// <summary> /// Determines how we'll be selecting our main title for entries. /// </summary> @@ -137,11 +132,6 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public TitleProvider[] TitleMainOrder { get; set; } - /// <summary> - /// Determines if we use the overridden settings for how the alternate title is fetched for entries. - /// </summary> - public bool TitleAlternateOverride { get; set; } - /// <summary> /// Determines how we'll be selecting our alternate title for entries. /// </summary> @@ -164,11 +154,6 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool MarkSpecialsWhenGrouped { get; set; } - /// <summary> - /// Determines if we use the overridden settings for how descriptions are fetched for entries. - /// </summary> - public bool DescriptionSourceOverride { get; set; } - /// <summary> /// The collection of providers for descriptions. Replaces the former `DescriptionSource`. /// </summary> @@ -204,11 +189,6 @@ public DescriptionProvider[] ThirdPartyIdProviderList #region Tags - /// <summary> - /// Determines if we use the overridden settings for how the tags are set for entries. - /// </summary> - public bool TagOverride { get; set; } - /// <summary> /// All tag sources to use for tags. /// </summary> @@ -232,11 +212,6 @@ public DescriptionProvider[] ThirdPartyIdProviderList [Range(0, 10)] public int TagMaximumDepth { get; set; } - /// <summary> - /// Determines if we use the overridden settings for how the genres are set for entries. - /// </summary> - public bool GenreOverride { get; set; } - /// <summary> /// All tag sources to use for genres. /// </summary> @@ -265,11 +240,6 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool HideUnverifiedTags { get; set; } - /// <summary> - /// Determines if we use the overridden settings for how the content/official ratings are set for entries. - /// </summary> - public bool ContentRatingOverride { get; set; } - /// <summary> /// Enabled content rating providers. /// </summary> @@ -280,11 +250,6 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public ProviderName[] ContentRatingOrder { get; set; } - /// <summary> - /// Determines if we use the overridden settings for how the production locations are set for entries. - /// </summary> - public bool ProductionLocationOverride { get; set; } - /// <summary> /// Enabled production location providers. /// </summary> @@ -558,7 +523,37 @@ public PluginConfiguration() PublicUrl = string.Empty; Username = "Default"; ApiKey = string.Empty; - TagOverride = false; + + TitleMainList = [ + TitleProvider.Shoko_Default, + ]; + TitleMainOrder = [ + TitleProvider.Shoko_Default, + TitleProvider.AniDB_Default, + TitleProvider.AniDB_LibraryLanguage, + TitleProvider.AniDB_CountryOfOrigin, + TitleProvider.TMDB_Default, + TitleProvider.TMDB_LibraryLanguage, + TitleProvider.TMDB_CountryOfOrigin, + ]; + TitleAlternateList = []; + TitleAlternateOrder = [.. TitleMainOrder]; + TitleAllowAny = false; + MarkSpecialsWhenGrouped = true; + SynopsisCleanLinks = true; + SynopsisCleanMiscLines = true; + SynopsisRemoveSummary = true; + SynopsisCleanMultiEmptyLines = true; + DescriptionSourceList = [ + DescriptionProvider.Shoko, + ]; + DescriptionSourceOrder = [ + DescriptionProvider.Shoko, + DescriptionProvider.AniDB, + DescriptionProvider.TMDB, + DescriptionProvider.TvDB, + ]; + HideUnverifiedTags = true; TagSources = TagSource.ContentIndicators | TagSource.Dynamic | TagSource.DynamicCast | TagSource.DynamicEnding | TagSource.Elements | TagSource.ElementsPornographyAndSexualAbuse | TagSource.ElementsTropesAndMotifs | TagSource.Fetishes | TagSource.OriginProduction | TagSource.OriginDevelopment | TagSource.SourceMaterial | TagSource.SettingPlace | @@ -572,55 +567,20 @@ public PluginConfiguration() GenreIncludeFilters = TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted; GenreMinimumWeight = TagWeight.Four; GenreMaximumDepth = 1; - HideUnverifiedTags = true; - ContentRatingOverride = false; ContentRatingList = [ ProviderName.TMDB, ProviderName.AniDB, ]; ContentRatingOrder = [.. ContentRatingList]; - ProductionLocationOverride = false; ProductionLocationList = [ ProviderName.AniDB, ProviderName.TMDB, ]; ProductionLocationOrder = [.. ProductionLocationList]; - SynopsisCleanLinks = true; - SynopsisCleanMiscLines = true; - SynopsisRemoveSummary = true; - SynopsisCleanMultiEmptyLines = true; AddAniDBId = true; AddTMDBId = false; AddTvDBId = false; - TitleMainOverride = false; - TitleMainList = [ - TitleProvider.Shoko_Default, - ]; - TitleMainOrder = [ - TitleProvider.Shoko_Default, - TitleProvider.AniDB_Default, - TitleProvider.AniDB_LibraryLanguage, - TitleProvider.AniDB_CountryOfOrigin, - TitleProvider.TMDB_Default, - TitleProvider.TMDB_LibraryLanguage, - TitleProvider.TMDB_CountryOfOrigin, - ]; - TitleAlternateOverride = false; - TitleAlternateList = [ - TitleProvider.AniDB_CountryOfOrigin - ]; - TitleAlternateOrder = [.. TitleMainOrder]; - TitleAllowAny = false; - DescriptionSourceOverride = false; - DescriptionSourceList = [ - DescriptionProvider.Shoko, - ]; - DescriptionSourceOrder = [ - DescriptionProvider.Shoko, - DescriptionProvider.AniDB, - DescriptionProvider.TMDB, - DescriptionProvider.TvDB, - ]; + VFS_Enabled = true; VFS_Threads = 4; VFS_AddReleaseGroup = false; @@ -642,7 +602,6 @@ public PluginConfiguration() SeasonOrdering = OrderType.Default; SpecialsPlacement = SpecialOrderType.AfterSeason; AddMissingMetadata = true; - MarkSpecialsWhenGrouped = true; CollectionGrouping = CollectionCreationType.None; CollectionMinSizeOfTwo = true; UserList = []; diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index 21b473ca..d020d7cd 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -140,69 +140,6 @@ createControllerFactory({ } }); - form.querySelector("#TitleMainOverride").addEventListener("change", function () { - const list = form.querySelector(`#TitleMainList`); - this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); - }); - - form.querySelector("#TitleAlternateOverride").addEventListener("change", function () { - const list = form.querySelector(`#TitleAlternateList`); - this.checked ? list.removeAttribute("hidden") : list.setAttribute("hidden", ""); - }); - - form.querySelector("#DescriptionSourceOverride").addEventListener("change", function () { - const list = form.querySelector("#DescriptionSourceList"); - this.checked ? list.removeAttribute("hidden") : list.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; - form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#TagMaximumDepthContainer").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("#TagMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#TagMaximumDepthContainer").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; - form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#GenreMaximumDepthContainer").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("#GenreMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#GenreMaximumDepthContainer").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", ""); - }); - form.addEventListener("submit", function (event) { event.preventDefault(); if (!event.submitter) return; @@ -438,32 +375,25 @@ function updateSignalrStatus(form, status) { function applyFormToConfig(form, config) { switch (State.currentTab) { case "metadata": { - config.TitleMainOverride = form.querySelector("#TitleMainOverride").checked; ([config.TitleMainList, config.TitleMainOrder] = retrieveSortableCheckboxList(form, "TitleMainList")); - config.TitleAlternateOverride = form.querySelector("#TitleAlternateOverride").checked; ([config.TitleAlternateList, config.TitleAlternateOrder] = retrieveSortableCheckboxList(form, "TitleAlternateList")); config.TitleAllowAny = form.querySelector("#TitleAllowAny").checked; config.MarkSpecialsWhenGrouped = form.querySelector("#MarkSpecialsWhenGrouped").checked; - config.DescriptionSourceOverride = form.querySelector("#DescriptionSourceOverride").checked; ([config.DescriptionSourceList, config.DescriptionSourceOrder] = retrieveSortableCheckboxList(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 = retrieveCheckboxList(form, "TagSources").join(", "); config.TagIncludeFilters = retrieveCheckboxList(form, "TagIncludeFilters").join(", "); config.TagMinimumWeight = form.querySelector("#TagMinimumWeight").value; config.TagMaximumDepth = parseInt(form.querySelector("#TagMaximumDepth").value, 10); - config.GenreOverride = form.querySelector("#GenreOverride").checked; config.GenreSources = retrieveCheckboxList(form, "GenreSources").join(", "); config.GenreIncludeFilters = retrieveCheckboxList(form, "GenreIncludeFilters").join(", "); config.GenreMinimumWeight = form.querySelector("#GenreMinimumWeight").value; config.GenreMaximumDepth = parseInt(form.querySelector("#GenreMaximumDepth").value, 10); - config.ContentRatingOverride = form.querySelector("#ContentRatingOverride").checked; ([config.ContentRatingList, config.ContentRatingOrder] = retrieveSortableCheckboxList(form, "ContentRatingList")); - config.ProductionLocationOverride = form.querySelector("#ProductionLocationOverride").checked; ([config.ProductionLocationList, config.ProductionLocationOrder] = retrieveSortableCheckboxList(form, "ProductionLocationList")); config.ThirdPartyIdProviderList = retrieveCheckboxList(form, "ThirdPartyIdProviderList"); break; @@ -591,28 +521,10 @@ async function applyConfigToForm(form, config) { } case "metadata": { - if (form.querySelector("#TitleMainOverride").checked = config.TitleMainOverride) { - form.querySelector("#TitleMainList").removeAttribute("hidden"); - } - else { - form.querySelector("#TitleMainList").setAttribute("hidden", ""); - } renderSortableCheckboxList(form, "TitleMainList", config.TitleMainList, config.TitleMainOrder); - if (form.querySelector("#TitleAlternateOverride").checked = config.TitleAlternateOverride) { - form.querySelector("#TitleAlternateList").removeAttribute("hidden"); - } - else { - form.querySelector("#TitleAlternateList").setAttribute("hidden", ""); - } renderSortableCheckboxList(form, "TitleAlternateList", config.TitleAlternateList, config.TitleAlternateOrder); form.querySelector("#TitleAllowAny").checked = config.TitleAllowAny; form.querySelector("#MarkSpecialsWhenGrouped").checked = config.MarkSpecialsWhenGrouped; - if (form.querySelector("#DescriptionSourceOverride").checked = config.DescriptionSourceOverride) { - form.querySelector("#DescriptionSourceList").removeAttribute("hidden"); - } - else { - form.querySelector("#DescriptionSourceList").setAttribute("hidden", ""); - } renderSortableCheckboxList(form, "DescriptionSourceList", config.DescriptionSourceList, config.DescriptionSourceOrder); form.querySelector("#CleanupAniDBDescriptions").checked = ( config.SynopsisCleanMultiEmptyLines || @@ -621,60 +533,15 @@ async function applyConfigToForm(form, config) { 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; - form.querySelector("#TagMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#TagMaximumDepthContainer").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("#TagMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#TagMaximumDepthContainer").disabled = true; - } renderCheckboxList(form, "TagSources", config.TagSources.split(",").map(s => s.trim()).filter(s => s)); renderCheckboxList(form, "TagIncludeFilters", config.TagIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); form.querySelector("#TagMinimumWeight").value = config.TagMinimumWeight; form.querySelector("#TagMaximumDepth").value = config.TagMaximumDepth.toString(); - 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; - form.querySelector("#GenreMaximumDepthContainer").removeAttribute("hidden"); - form.querySelector("#GenreMaximumDepthContainer").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("#GenreMaximumDepthContainer").setAttribute("hidden", ""); - form.querySelector("#GenreMaximumDepthContainer").disabled = true; - } renderCheckboxList(form, "GenreSources", config.GenreSources.split(",").map(s => s.trim()).filter(s => s)); renderCheckboxList(form, "GenreIncludeFilters", config.GenreIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); form.querySelector("#GenreMinimumWeight").value = config.GenreMinimumWeight; form.querySelector("#GenreMaximumDepth").value = config.GenreMaximumDepth.toString(); - - if (form.querySelector("#ContentRatingOverride").checked = config.ContentRatingOverride) { - form.querySelector("#ContentRatingList").removeAttribute("hidden"); - } - else { - form.querySelector("#ContentRatingList").setAttribute("hidden", ""); - } renderSortableCheckboxList(form, "ContentRatingList", config.ContentRatingList, config.ContentRatingOrder); - if (form.querySelector("#ProductionLocationOverride").checked = config.ProductionLocationOverride) { - form.querySelector("#ProductionLocationList").removeAttribute("hidden"); - } - else { - form.querySelector("#ProductionLocationList").setAttribute("hidden", ""); - } renderSortableCheckboxList(form, "ProductionLocationList", config.ProductionLocationList, config.ProductionLocationOrder); renderCheckboxList(form, "ThirdPartyIdProviderList", config.ThirdPartyIdProviderList.map(s => s.trim()).filter(s => s)); break; diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 12d576d7..0f6c942a 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -63,17 +63,8 @@ <h3>Title Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Will add any title in the selected language if no official title is found.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="TitleMainOverride" /> - <span>Override main title</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - Enables the advanced selector for the main title selection. - </div> - </div> <div id="TitleMainList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced main title source:</h3> + <h3 class="checkboxListLabel">Main title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> @@ -162,17 +153,8 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </div> <div class="fieldDescription">The metadata providers to use as the source of the main title, in priority order.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="TitleAlternateOverride" /> - <span>Override alternate title</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - Enables the advanced selector for the alternate title selection. - </div> - </div> <div id="TitleAlternateList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced alternate title source:</h3> + <h3 class="checkboxListLabel">Alternate/original title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> @@ -259,7 +241,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </button> </div> </div> - <div class="fieldDescription">The metadata providers to use as the source of the alternate title, in priority order.</div> + <div class="fieldDescription">The metadata providers to use as the source of the alternate/original title, in priority order.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -276,17 +258,8 @@ <h3>Description Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="DescriptionSourceOverride" /> - <span>Override description source</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - Enables the advanced selector for description source selection. - </div> - </div> <div id="DescriptionSourceList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced description source:</h3> + <h3 class="checkboxListLabel">Description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="Shoko"> <label class="listItemCheckboxContainer"> @@ -356,17 +329,8 @@ <h3>Tag & Genre Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="TagOverride" /> - <span>Override tag sources</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - Enables the advanced selector for tag source selection. - </div> - </div> <div id="TagSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced tag sources:</h3> + <h3 class="checkboxListLabel">Tag sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -597,7 +561,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> <div class="fieldDescription">The tag sources to use as the source of tags.</div> </div> <div id="TagIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced tag include filters:</h3> + <h3 class="checkboxListLabel">Tag include filters:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -665,8 +629,8 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> </div> <div class="fieldDescription">The type of tags to include for tags.</div> </div> - <div id="TagMinimumWeightContainer" class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="TagMinimumWeight">Advanced minimum tag weight for tags:</label> + <div class="selectContainer selectContainer-withDescription expert-only"> + <label class="selectLabel" for="TagMinimumWeight">Minimum tag weight for tags:</label> <select is="emby-select" id="TagMinimumWeight" name="TagMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless" selected>Disabled (Default)</option> <option value="One">⯪☆☆</option> @@ -680,21 +644,12 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. </div> </div> - <div id="TagMaximumDepthContainer" class="inputContainer inputContainer-withDescription expert-only"> + <div class="inputContainer inputContainer-withDescription expert-only"> <input is="emby-input" id="TagMaximumDepth" label="Maximum depth to add per tag source:" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for tags.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="GenreOverride" /> - <span>Override genre sources</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - Enables the advanced selector for genre source selection. - </div> - </div> <div id="GenreSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced genre sources:</h3> + <h3 class="checkboxListLabel">Genre sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -925,7 +880,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> <div class="fieldDescription">The tag sources to use as the source of genres.</div> </div> <div id="GenreIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced genre include filters:</h3> + <h3 class="checkboxListLabel">Genre include filters:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -993,8 +948,8 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> </div> <div class="fieldDescription">The type of tags to include for genres.</div> </div> - <div id="GenreMinimumWeightContainer" class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="GenreMinimumWeight">Advanced minimum tag weight for genres:</label> + <div class="selectContainer selectContainer-withDescription expert-only"> + <label class="selectLabel" for="GenreMinimumWeight">Minimum tag weight for genres:</label> <select is="emby-select" id="GenreMinimumWeight" name="GenreMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless">Disabled</option> <option value="One">⯪☆☆</option> @@ -1008,7 +963,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. </div> </div> - <div id="GenreMaximumDepthContainer" class="inputContainer inputContainer-withDescription expert-only"> + <div class="inputContainer inputContainer-withDescription expert-only"> <input is="emby-input" id="GenreMaximumDepth" label="Maximum depth to add per genre source:" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for genres.</div> </div> @@ -1020,56 +975,38 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <legend> <h3>Miscellaneous Settings</h3> </legend> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="ContentRatingOverride" /> - <span>Override content rating sources</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - Enables the advanced selector for content rating source selection. - </div> - </div> <div id="ContentRatingList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced content rating sources:</h3> + <h3 class="checkboxListLabel">Content rating sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem sortableOption" data-option="TMDB"> + <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TMDB | Follow country/region set in library</h3> + <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h3> </div> - <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> - <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> </button> </div> - <div class="listItem sortableOption" data-option="AniDB"> + <div class="listItem sortableOption" data-option="TMDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h3> + <h3 class="listItemBodyText">TMDB | Follow country/region set in library</h3> </div> - <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> - <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> </button> </div> </div> <div class="fieldDescription">The metadata providers to use as the source of content ratings, in priority order.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="ProductionLocationOverride" /> - <span>Override production location sources</span> - </label> - <div class="fieldDescription checkboxFieldDescription"> - Enables the advanced selector for production location source selection. - </div> - </div> <div id="ProductionLocationList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Advanced production location sources:</h3> + <h3 class="checkboxListLabel">Production location sources:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> @@ -1115,7 +1052,7 @@ <h3 class="checkboxListLabel">Add Third Party IDs:</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB</h3> + <h3 class="listItemBodyText">AniDB | Shows, Seasons, Episodes</h3> </div> </div> <div class="listItem"> @@ -1124,7 +1061,7 @@ <h3 class="listItemBodyText">AniDB</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TMDB</h3> + <h3 class="listItemBodyText">TMDB | Shows, Seasons, Episodes, Movies</h3> </div> </div> <div class="listItem"> @@ -1133,7 +1070,7 @@ <h3 class="listItemBodyText">TMDB</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TvDB</h3> + <h3 class="listItemBodyText">TvDB | Show, Episode</h3> </div> </div> </div> diff --git a/Shokofin/Utils/ContentRating.cs b/Shokofin/Utils/ContentRating.cs index 762c813b..3de6c5ce 100644 --- a/Shokofin/Utils/ContentRating.cs +++ b/Shokofin/Utils/ContentRating.cs @@ -154,9 +154,7 @@ public enum TvContentIndicator { } private static ProviderName[] GetOrderedProviders() - => Plugin.Instance.Configuration.ContentRatingOverride - ? Plugin.Instance.Configuration.ContentRatingOrder.Where((t) => Plugin.Instance.Configuration.ContentRatingList.Contains(t)).ToArray() - : [ProviderName.AniDB, ProviderName.TMDB]; + => Plugin.Instance.Configuration.ContentRatingOrder.Where((t) => Plugin.Instance.Configuration.ContentRatingList.Contains(t)).ToArray(); #pragma warning disable IDE0060 public static string? GetMovieContentRating(SeasonInfo seasonInfo, EpisodeInfo episodeInfo, string? metadataCountryCode) diff --git a/Shokofin/Utils/TagFilter.cs b/Shokofin/Utils/TagFilter.cs index 43b7ce28..78038e7c 100644 --- a/Shokofin/Utils/TagFilter.cs +++ b/Shokofin/Utils/TagFilter.cs @@ -319,9 +319,7 @@ public enum TagWeight { } private static ProviderName[] GetOrderedProductionLocationProviders() - => Plugin.Instance.Configuration.ProductionLocationOverride - ? Plugin.Instance.Configuration.ProductionLocationOrder.Where((t) => Plugin.Instance.Configuration.ProductionLocationList.Contains(t)).ToArray() - : [ProviderName.AniDB, ProviderName.TMDB]; + => Plugin.Instance.Configuration.ProductionLocationOrder.Where((t) => Plugin.Instance.Configuration.ProductionLocationList.Contains(t)).ToArray(); #pragma warning disable IDE0060 public static string[] GetMovieProductionLocations(SeasonInfo seasonInfo, EpisodeInfo episodeInfo) @@ -371,33 +369,12 @@ public static string[] GetShowProductionLocations(ShowInfo showInfo) public static string[] FilterTags(IReadOnlyDictionary<string, ResolvedTag> tags) { var config = Plugin.Instance.Configuration; - if (!config.TagOverride) - return FilterInternal( - tags, - TagSource.ContentIndicators | TagSource.Dynamic | TagSource.DynamicCast | TagSource.DynamicEnding | TagSource.Elements | - TagSource.ElementsPornographyAndSexualAbuse | TagSource.ElementsTropesAndMotifs | TagSource.Fetishes | - TagSource.OriginProduction | TagSource.OriginDevelopment | TagSource.SourceMaterial | TagSource.SettingPlace | - TagSource.SettingTimePeriod | TagSource.SettingTimeSeason | TagSource.TargetAudience | TagSource.TechnicalAspects | - TagSource.TechnicalAspectsAdaptions | TagSource.TechnicalAspectsAwards | TagSource.TechnicalAspectsMultiAnimeProjects | - TagSource.Themes | TagSource.ThemesDeath | TagSource.ThemesTales | TagSource.CustomTags, - TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted, - TagWeight.Weightless, - 0 - ); return FilterInternal(tags, config.TagSources, config.TagIncludeFilters, config.TagMinimumWeight, config.TagMaximumDepth); } public static string[] FilterGenres(IReadOnlyDictionary<string, ResolvedTag> tags) { var config = Plugin.Instance.Configuration; - if (!config.GenreOverride) - return FilterInternal( - tags, - TagSource.SourceMaterial | TagSource.TargetAudience | TagSource.Elements, - TagIncludeFilter.Parent | TagIncludeFilter.Child | TagIncludeFilter.Abstract | TagIncludeFilter.Weightless | TagIncludeFilter.Weighted, - TagWeight.Four, - 1 - ); return FilterInternal(tags, config.GenreSources, config.GenreIncludeFilters, config.GenreMinimumWeight, config.GenreMaximumDepth); } diff --git a/Shokofin/Utils/Text.cs b/Shokofin/Utils/Text.cs index 8b4c6f17..ee93b947 100644 --- a/Shokofin/Utils/Text.cs +++ b/Shokofin/Utils/Text.cs @@ -189,9 +189,7 @@ public static string GetMovieDescription(EpisodeInfo episode, SeasonInfo season, /// Returns a list of the description providers to check, and in what order /// </summary> private static DescriptionProvider[] GetOrderedDescriptionProviders() - => Plugin.Instance.Configuration.DescriptionSourceOverride - ? Plugin.Instance.Configuration.DescriptionSourceOrder.Where((t) => Plugin.Instance.Configuration.DescriptionSourceList.Contains(t)).ToArray() - : [DescriptionProvider.Shoko, DescriptionProvider.AniDB, DescriptionProvider.TvDB, DescriptionProvider.TMDB]; + => Plugin.Instance.Configuration.DescriptionSourceOrder.Where((t) => Plugin.Instance.Configuration.DescriptionSourceList.Contains(t)).ToArray(); private static string GetDescriptionByDict(Dictionary<DescriptionProvider, string?> descriptions) { @@ -332,13 +330,9 @@ public static (string?, string?) GetMovieTitles(EpisodeInfo episodeInfo, SeasonI private static TitleProvider[] GetOrderedTitleProvidersByType(TitleProviderType titleType) => titleType switch { TitleProviderType.Main => - Plugin.Instance.Configuration.TitleMainOverride - ? Plugin.Instance.Configuration.TitleMainOrder.Where((t) => Plugin.Instance.Configuration.TitleMainList.Contains(t)).ToArray() - : [TitleProvider.Shoko_Default], + Plugin.Instance.Configuration.TitleMainOrder.Where((t) => Plugin.Instance.Configuration.TitleMainList.Contains(t)).ToArray(), TitleProviderType.Alternate => - Plugin.Instance.Configuration.TitleAlternateOverride - ? Plugin.Instance.Configuration.TitleAlternateOrder.Where((t) => Plugin.Instance.Configuration.TitleAlternateList.Contains(t)).ToArray() - : [TitleProvider.AniDB_CountryOfOrigin, TitleProvider.TMDB_CountryOfOrigin], + Plugin.Instance.Configuration.TitleAlternateOrder.Where((t) => Plugin.Instance.Configuration.TitleAlternateList.Contains(t)).ToArray(), _ => [], }; From fac3ce6d26f406a1416e31732351bdba2e5c3181 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 01:35:26 +0200 Subject: [PATCH 106/144] refactor: get movie images using episode id --- Shokofin/API/ShokoAPIClient.cs | 26 ++++++++++++++++++++++---- Shokofin/Providers/ImageProvider.cs | 12 +++++++----- 2 files changed, 29 insertions(+), 9 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index cfa84dd7..8e4a4ae9 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -343,11 +343,29 @@ public Task<Episode> GetEpisode(string id) public async Task<EpisodeImages?> GetEpisodeImages(string id) { try { - if (AllowEpisodeImages) - return await Get<EpisodeImages>($"/api/v3/Episode/{id}/Images"); - var episode = await GetEpisode(id); + if (AllowEpisodeImages) { + var episodeImages = await Get<EpisodeImages>($"/api/v3/Episode/{id}/Images"); + // If the episode has no 'movie' images, get the series images to compensate. + if (episodeImages.Posters.Count is 0) { + var episode1 = await GetEpisode(id); + var seriesImages1 = await GetSeriesImages(episode1.IDs.ParentSeries.ToString()) ?? new(); + + episodeImages.Posters = seriesImages1.Posters; + episodeImages.Logos = seriesImages1.Logos; + episodeImages.Banners = seriesImages1.Banners; + episodeImages.Backdrops = seriesImages1.Backdrops; + } + return episodeImages; + } + + var episode0 = await GetEpisode(id); + var seriesImages0 = await GetSeriesImages(episode0.IDs.ParentSeries.ToString()) ?? new(); return new() { - Thumbnails = episode.TvDBEntityList.FirstOrDefault()?.Thumbnail is { } thumbnail ? [thumbnail] : [], + Banners = seriesImages0.Banners, + Backdrops = seriesImages0.Backdrops, + Posters = seriesImages0.Posters, + Logos = seriesImages0.Logos, + Thumbnails = episode0.TvDBEntityList.FirstOrDefault()?.Thumbnail is { } thumbnail ? [thumbnail] : [], }; } catch (ApiException e) when (e.StatusCode == HttpStatusCode.NotFound) { diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 0cc8aa54..f56f8675 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -106,11 +106,13 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell break; } case Movie movie: { - if (Lookup.TryGetSeriesIdFor(movie, out var seriesId)) { - var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); - Logger.LogInformation("Getting {Count} images for movie {MovieName} (Series={SeriesId})", list.Count, movie.Name, seriesId); + if (Lookup.TryGetEpisodeIdFor(movie, out var episodeId)) { + var episodeImages = await ApiClient.GetEpisodeImages(episodeId); + if (episodeImages is not null) { + AddImagesForSeries(ref list, episodeImages); + AddImagesForEpisode(ref list, episodeImages); + } + Logger.LogInformation("Getting {Count} images for movie {MovieName} (Episode={EpisodeId})", list.Count, movie.Name, episodeId); } break; } From 4e5e6a5130fa905919e55b730713106e12f86d40 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 01:38:40 +0200 Subject: [PATCH 107/144] misc: allow VFS to auto-select thread count based on cpu if setting is set to 0 --- Shokofin/Resolvers/VirtualFileSystemService.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index b4885efa..38fbd8b6 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -655,7 +655,8 @@ private async Task<LinkGenerationResult> GenerateStructure(CollectionType? colle var failedSeries = new HashSet<string>(); var failedExceptions = new List<Exception>(); var cancelTokenSource = new CancellationTokenSource(); - var semaphore = new SemaphoreSlim(Plugin.Instance.Configuration.VFS_Threads); + var threadCount = Plugin.Instance.Configuration.VFS_Threads is > 0 ? Plugin.Instance.Configuration.VFS_Threads : Environment.ProcessorCount; + var semaphore = new SemaphoreSlim(threadCount); await Task.WhenAll(allFiles.Select(async (tuple) => { await semaphore.WaitAsync().ConfigureAwait(false); var (sourceLocation, fileId, seriesId) = tuple; From 74d5a9f565945db4e5b8cf040a0798bf6182a288 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 01:39:12 +0200 Subject: [PATCH 108/144] misc: move server version in config --- Shokofin/Configuration/PluginConfiguration.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 049f5c4e..9f5d0c67 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -39,13 +39,6 @@ public class PluginConfiguration : BasePluginConfiguration [XmlElement("Host")] public string Url { get; set; } - /// <summary> - /// The last known server version. This is used for keeping compatibility - /// with multiple versions of the server. - /// </summary> - [XmlElement("HostVersion")] - public ComponentVersion? ServerVersion { get; set; } - [XmlElement("PublicHost")] public string PublicUrl { get; set; } @@ -65,6 +58,13 @@ public virtual string PrettyUrl /// </summary> public string ApiKey { get; set; } + /// <summary> + /// The last known server version. This is used for keeping compatibility + /// with multiple versions of the server. + /// </summary> + [XmlElement("HostVersion")] + public ComponentVersion? ServerVersion { get; set; } + #endregion #region Plugin Interoperability @@ -519,10 +519,10 @@ public DescriptionProvider[] ThirdPartyIdProviderList public PluginConfiguration() { Url = "http://127.0.0.1:8111"; - ServerVersion = null; PublicUrl = string.Empty; Username = "Default"; ApiKey = string.Empty; + ServerVersion = null; TitleMainList = [ TitleProvider.Shoko_Default, From 274294f437c8725d18587871942f6a9d4cab1f65 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 02:02:55 +0200 Subject: [PATCH 109/144] feat: add image metadata settings --- Shokofin/Configuration/PluginConfiguration.cs | 18 ++++ Shokofin/Pages/Scripts/Settings.js | 9 +- Shokofin/Pages/Settings.html | 22 ++++ Shokofin/Providers/ImageProvider.cs | 102 +++++++++++------- 4 files changed, 113 insertions(+), 38 deletions(-) diff --git a/Shokofin/Configuration/PluginConfiguration.cs b/Shokofin/Configuration/PluginConfiguration.cs index 9f5d0c67..19ae9867 100644 --- a/Shokofin/Configuration/PluginConfiguration.cs +++ b/Shokofin/Configuration/PluginConfiguration.cs @@ -19,6 +19,7 @@ namespace Shokofin.Configuration; +// TODO: Split this up in the transition to 5.0 into multiple sub-classes. public class PluginConfiguration : BasePluginConfiguration { #region Connection @@ -185,6 +186,21 @@ public DescriptionProvider[] ThirdPartyIdProviderList /// </summary> public bool SynopsisCleanMultiEmptyLines { get; set; } + /// <summary> + /// Add language code to image metadata provided to Jellyfin for it to + /// select the correct image to use for the library. + /// </summary> + public bool AddImageLanguageCode { get; set; } + + /// <summary> + /// Respect the preferred image flag sent from server when selecting the + /// correct image to use for the library. Setting this will also set the + /// language code to the preferred language code for the library if + /// <see cref="AddImageLanguageCode"/> is enabled, thus ensuring it is + /// always selected for the library. + /// </summary> + public bool RespectPreferredImage { get; set; } + #endregion #region Tags @@ -544,6 +560,8 @@ public PluginConfiguration() SynopsisCleanMiscLines = true; SynopsisRemoveSummary = true; SynopsisCleanMultiEmptyLines = true; + AddImageLanguageCode = false; + RespectPreferredImage = true; DescriptionSourceList = [ DescriptionProvider.Shoko, ]; diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index d020d7cd..75e7a498 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -34,7 +34,7 @@ promise.then(({ //#region Constants /** - * @typedef {"Connection" | "Metadata_Title" | "Metadata_Description" | "Metadata_TagGenre" | "Metadata_Misc" | "Metadata_ThirdPartyIntegration" | "Library_Basic" | "Library_Collection" | "Library_New" | "Library_Existing" | "Library_Experimental" | "VFS_Basic" | "VFS_Location" | "User" | "SignalR_Connection" | "SignalR_Basic" | "SignalR_Library_New" | "SignalR_Library_Existing" | "Misc" | "Utilities"} SectionType + * @typedef {"Connection" | "Metadata_Title" | "Metadata_Description" | "Metadata_TagGenre" | "Metadata_Image" | "Metadata_Misc" | "Metadata_ThirdPartyIntegration" | "Library_Basic" | "Library_Collection" | "Library_New" | "Library_Existing" | "Library_Experimental" | "VFS_Basic" | "VFS_Location" | "User" | "SignalR_Connection" | "SignalR_Basic" | "SignalR_Library_New" | "SignalR_Library_Existing" | "Misc" | "Utilities"} SectionType */ const MaxDebugPresses = 7; @@ -47,6 +47,7 @@ const Sections = [ "Metadata_Title", "Metadata_Description", "Metadata_TagGenre", + "Metadata_Image", "Metadata_Misc", "Metadata_ThirdPartyIntegration", "Library_Basic", @@ -290,7 +291,7 @@ async function updateView(view, form, config) { break; case "metadata": - activeSections.push("Metadata_Title", "Metadata_Description", "Metadata_TagGenre", "Metadata_Misc", "Metadata_ThirdPartyIntegration"); + activeSections.push("Metadata_Title", "Metadata_Description", "Metadata_TagGenre", "Metadata_Image", "Metadata_Misc", "Metadata_ThirdPartyIntegration"); break; case "library": @@ -384,6 +385,8 @@ function applyFormToConfig(form, config) { config.SynopsisCleanMultiEmptyLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisCleanMiscLines = form.querySelector("#CleanupAniDBDescriptions").checked; config.SynopsisRemoveSummary = form.querySelector("#CleanupAniDBDescriptions").checked; + config.AddImageLanguageCode = form.querySelector("#AddImageLanguageCode").checked; + config.RespectPreferredImage = form.querySelector("#RespectPreferredImage").checked; config.HideUnverifiedTags = form.querySelector("#HideUnverifiedTags").checked; config.TagSources = retrieveCheckboxList(form, "TagSources").join(", "); config.TagIncludeFilters = retrieveCheckboxList(form, "TagIncludeFilters").join(", "); @@ -532,6 +535,8 @@ async function applyConfigToForm(form, config) { config.SynopsisRemoveSummary || config.SynopsisCleanMiscLines ); + form.querySelector("#AddImageLanguageCode").checked = config.AddImageLanguageCode; + form.querySelector("#RespectPreferredImage").checked = config.RespectPreferredImage; form.querySelector("#HideUnverifiedTags").checked = config.HideUnverifiedTags; renderCheckboxList(form, "TagSources", config.TagSources.split(",").map(s => s.trim()).filter(s => s)); renderCheckboxList(form, "TagIncludeFilters", config.TagIncludeFilters.split(",").map(s => s.trim()).filter(s => s)); diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 0f6c942a..213da957 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -971,6 +971,28 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <span>Save</span> </button> </fieldset> + <fieldset id="Metadata_Image_Section" class="verticalSection verticalSection-extrabottompadding" hidden> + <legend> + <h3>Image Settings</h3> + </legend> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="AddImageLanguageCode" /> + <span>Add language code</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add language code to image metadata provided to Jellyfin for it to select the correct image to use for the library.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="RespectPreferredImage" /> + <span>Respect preferred image</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Respect the preferred image flag sent from server when selecting the correct image to use for the library. Setting this will also set the language code to the preferred language code for the library if "Add language code" is enabled, thus ensuring it is always selected for the library.</div> + </div> + <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> + <span>Save</span> + </button> + </fieldset> <fieldset id="Metadata_Misc_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> <legend> <h3>Miscellaneous Settings</h3> diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index f56f8675..862ec2a8 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -44,6 +44,7 @@ public ImageProvider(IHttpClientFactory httpClientFactory, ILogger<ImageProvider public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var list = new List<RemoteImageInfo>(); + var metadataLanguage = item.GetPreferredMetadataLanguage(); var baseKind = item.GetBaseItemKind(); var trackerId = Plugin.Instance.Tracker.Add($"Providing images for {baseKind} \"{item.Name}\". (Path=\"{item.Path}\")"); try { @@ -52,28 +53,34 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { var episodeImages = await ApiClient.GetEpisodeImages(episodeId); if (episodeImages is not null) - AddImagesForEpisode(ref list, episodeImages); - Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId})", list.Count, episode.Name, episodeId); - } - break; + AddImagesForEpisode(ref list, episodeImages, metadataLanguage); + Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId},Language={MetadataLanguage})", list.Count, episode.Name, episodeId, metadataLanguage); + } break; } case Series series: { if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + var sortPreferred = true; + if (seriesImages is not null) { + AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); + sortPreferred = false; + } // Also attach any images linked to the "seasons" (AKA series within the group). var showInfo = await ApiManager.GetShowInfoForSeries(seriesId); if (showInfo is not null && !showInfo.IsStandalone) { foreach (var seasonInfo in showInfo.SeasonList) { seriesImages = await ApiClient.GetSeriesImages(seasonInfo.Id); - if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + if (seriesImages is not null) { + AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); + sortPreferred = false; + } if (seasonInfo?.ExtraIds is not null) { foreach (var extraId in seasonInfo.ExtraIds) { seriesImages = await ApiClient.GetSeriesImages(extraId); - if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + if (seriesImages is not null) { + AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); + sortPreferred = false; + } } } } @@ -81,7 +88,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell .DistinctBy(image => image.Url) .ToList(); } - Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId})", list.Count, series.Name, seriesId); + Logger.LogInformation("Getting {Count} images for series {SeriesName} (Series={SeriesId},Language={MetadataLanguage})", list.Count, series.Name, seriesId, metadataLanguage); } break; } @@ -89,30 +96,33 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) { var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); var seriesImages = await ApiClient.GetSeriesImages(seriesId); - if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + var sortPreferred = true; + if (seriesImages is not null) { + AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); + sortPreferred = false; + } if (seasonInfo?.ExtraIds is not null) { foreach (var extraId in seasonInfo.ExtraIds) { seriesImages = await ApiClient.GetSeriesImages(extraId); - if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); + if (seriesImages is not null) { + AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); + sortPreferred = false; + } } list = list .DistinctBy(image => image.Url) .ToList(); } - Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId})", list.Count, season.IndexNumber, season.SeriesName, seriesId); + Logger.LogInformation("Getting {Count} images for season {SeasonNumber} in {SeriesName} (Series={SeriesId},Language={MetadataLanguage})", list.Count, season.IndexNumber, season.SeriesName, seriesId, metadataLanguage); } break; } case Movie movie: { if (Lookup.TryGetEpisodeIdFor(movie, out var episodeId)) { var episodeImages = await ApiClient.GetEpisodeImages(episodeId); - if (episodeImages is not null) { - AddImagesForSeries(ref list, episodeImages); - AddImagesForEpisode(ref list, episodeImages); - } - Logger.LogInformation("Getting {Count} images for movie {MovieName} (Episode={EpisodeId})", list.Count, movie.Name, episodeId); + if (episodeImages is not null) + AddImagesForSeries(ref list, episodeImages, metadataLanguage); + Logger.LogInformation("Getting {Count} images for movie {MovieName} (Episode={EpisodeId},Language={MetadataLanguage})", list.Count, movie.Name, episodeId, metadataLanguage); } break; } @@ -124,8 +134,8 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (!string.IsNullOrEmpty(seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages); - Logger.LogInformation("Getting {Count} images for collection {CollectionName} (Group={GroupId},Series={SeriesId})", list.Count, collection.Name, groupId, groupId == null ? seriesId : null); + AddImagesForSeries(ref list, seriesImages, metadataLanguage); + Logger.LogInformation("Getting {Count} images for collection {CollectionName} (Group={GroupId},Series={SeriesId},Language={MetadataLanguage})", list.Count, collection.Name, groupId, groupId == null ? seriesId : null, metadataLanguage); } break; } @@ -133,7 +143,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell return list; } catch (Exception ex) { - Logger.LogError(ex, "Threw unexpectedly; {Message}", ex.Message); + Logger.LogError(ex, "Threw unexpectedly for {BaseKind} {Name}; {Message}", baseKind, item.Name, ex.Message); return list; } finally { @@ -141,34 +151,53 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } } - public static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Models.EpisodeImages images) + public static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Models.EpisodeImages images, string metadataLanguage) { foreach (var image in images.Thumbnails.OrderByDescending(image => image.IsPreferred)) - AddImage(ref list, ImageType.Primary, image); + AddImage(ref list, ImageType.Primary, image, metadataLanguage); } - private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images) + private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images, string metadataLanguage, bool sortList = true) { - foreach (var image in images.Posters.OrderByDescending(image => image.IsPreferred)) - AddImage(ref list, ImageType.Primary, image); - foreach (var image in images.Backdrops.OrderByDescending(image => image.IsPreferred)) - AddImage(ref list, ImageType.Backdrop, image); - foreach (var image in images.Banners.OrderByDescending(image => image.IsPreferred)) - AddImage(ref list, ImageType.Banner, image); - foreach (var image in images.Logos.OrderByDescending(image => image.IsPreferred)) - AddImage(ref list, ImageType.Logo, image); + IEnumerable<API.Models.Image> imagesList = sortList + ? images.Posters.OrderByDescending(image => image.IsPreferred) + : images.Posters; + foreach (var image in imagesList) + AddImage(ref list, ImageType.Primary, image, sortList ? metadataLanguage : null); + + imagesList = sortList + ? images.Backdrops.OrderByDescending(image => image.IsPreferred) + : images.Backdrops; + foreach (var image in imagesList) + AddImage(ref list, ImageType.Backdrop, image, sortList ? metadataLanguage : null); + + imagesList = sortList + ? images.Banners.OrderByDescending(image => image.IsPreferred) + : images.Banners; + foreach (var image in imagesList) + AddImage(ref list, ImageType.Banner, image, sortList ? metadataLanguage : null); + + imagesList = sortList + ? images.Logos.OrderByDescending(image => image.IsPreferred) + : images.Logos; + foreach (var image in imagesList) + AddImage(ref list, ImageType.Logo, image, sortList ? metadataLanguage : null); } - private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image? image) + private static void AddImage(ref List<RemoteImageInfo> list, ImageType imageType, API.Models.Image? image, string? metadataLanguage) { if (image == null || !image.IsAvailable) return; + list.Add(new RemoteImageInfo { ProviderName = Plugin.MetadataProviderName, Type = imageType, Width = image.Width, Height = image.Height, Url = image.ToURLString(), + Language = Plugin.Instance.Configuration.AddImageLanguageCode + ? !string.IsNullOrEmpty(metadataLanguage) && image.IsPreferred ? metadataLanguage : image.LanguageCode + : null, }); } @@ -187,3 +216,4 @@ public async Task<HttpResponseMessage> GetImageResponse(string url, Cancellation return await HttpClientFactory.CreateClient().GetAsync(url, cancellationToken); } } + From 6044d90072de4376b9c1bfd76f9a75dd08b99b64 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 02:04:59 +0200 Subject: [PATCH 110/144] misc: demote title selectors - Demote title selectors from expert/advanced mode to not needing it. - Move checkboxes below the title selectors. --- Shokofin/Pages/Settings.html | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 213da957..6404a16b 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -49,21 +49,7 @@ <h3>Connection Settings</h3> <legend> <h3>Title Settings</h3> </legend> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Add prefix to episode titles</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="TitleAllowAny" /> - <span>Allow any title in selected language</span> - </label> - <div class="fieldDescription checkboxFieldDescription">Will add any title in the selected language if no official title is found.</div> - </div> - <div id="TitleMainList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> + <div id="TitleMainList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Main title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> @@ -153,7 +139,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </div> <div class="fieldDescription">The metadata providers to use as the source of the main title, in priority order.</div> </div> - <div id="TitleAlternateList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> + <div id="TitleAlternateList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Alternate/original title source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> @@ -243,6 +229,20 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </div> <div class="fieldDescription">The metadata providers to use as the source of the alternate/original title, in priority order.</div> </div> + <div class="checkboxContainer checkboxContainer-withDescription"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> + <span>Add prefix to episode titles</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="TitleAllowAny" /> + <span>Allow any title in selected language</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Will add any title in the selected language if no official title is found.</div> + </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> </button> From b575f8f6c6560dd743907ef3a1116ff600c6b456 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 02:25:26 +0200 Subject: [PATCH 111/144] fix: replace sync locking with async locking - Replaced the sync locking using `lock (obj)` in the media folder configuration service with async locking using a semaphore slim. --- .../MediaFolderConfigurationService.cs | 47 ++++++++++++++----- Shokofin/Resolvers/ShokoIgnoreRule.cs | 2 +- .../Resolvers/VirtualFileSystemService.cs | 2 +- 3 files changed, 36 insertions(+), 15 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index f30c46ca..3e3ed887 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -64,7 +64,7 @@ public class MediaFolderConfigurationService private bool ShouldGenerateAllConfigurations = true; - private readonly object LockObj = new(); + private readonly SemaphoreSlim LockObj = new(1, 1); public event EventHandler<MediaConfigurationChangedEventArgs>? ConfigurationAdded; @@ -109,6 +109,7 @@ NamingOptions namingOptions LibraryScanWatcher.ValueChanged -= OnLibraryScanValueChanged; UsageTracker.Stalled -= OnUsageTrackerStalled; MediaFolderChangeKeys.Clear(); + LockObj.Dispose(); } #region Changes Tracking @@ -126,9 +127,10 @@ private void OnUsageTrackerStalled(object? sender, EventArgs eventArgs) Task.Run(() => EditLibraries(false)); } - private void EditLibraries(bool shouldScheduleLibraryScan) + private async Task EditLibraries(bool shouldScheduleLibraryScan) { - lock (LockObj) { + await LockObj.WaitAsync().ConfigureAwait(false); + try { ShouldGenerateAllConfigurations = true; if (LibraryEdits.Count is 0) @@ -153,7 +155,10 @@ private void EditLibraries(bool shouldScheduleLibraryScan) LibraryManager.RemoveMediaPath(libraryName, new(vfsPath)); } if (shouldScheduleLibraryScan) - LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); + await LibraryManager.ValidateMediaLibrary(new Progress<double>(), CancellationToken.None); + } + finally { + LockObj.Release(); } } @@ -173,11 +178,12 @@ private void OnConfigurationChanged(object? sender, PluginConfiguration config) } } - private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) + private async void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) { var root = LibraryManager.RootFolder; if (e.Item != null && root != null && e.Item != root && e.Item is Folder folder && folder.ParentId == Guid.Empty && !string.IsNullOrEmpty(folder.Path) && !folder.Path.StartsWith(root.Path)) { - lock (LockObj) { + await LockObj.WaitAsync(); + try { var mediaFolderConfig = Plugin.Instance.Configuration.MediaFolders.FirstOrDefault(c => c.MediaFolderId == folder.Id); if (mediaFolderConfig != null) { Logger.LogDebug( @@ -193,6 +199,9 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) ConfigurationRemoved?.Invoke(null, new(mediaFolderConfig, folder)); } } + finally { + LockObj.Release(); + } } } @@ -202,7 +211,8 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) public IReadOnlyList<(string vfsPath, string mainMediaFolderPath, CollectionType? collectionType, IReadOnlyList<MediaFolderConfiguration> mediaList)> GetAvailableMediaFoldersForLibrariesForEvents(Func<MediaFolderConfiguration, bool>? filter = null) { - lock (LockObj) { + LockObj.Wait(); + try { var virtualFolders = LibraryManager.GetVirtualFolders(); return Plugin.Instance.Configuration.MediaFolders .Where(config => config.IsMapped && !config.IsVirtualRoot && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) @@ -224,12 +234,16 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) .Where(tuple => !string.IsNullOrEmpty(tuple.vfsPath) && !string.IsNullOrEmpty(tuple.mainMediaFolderPath)) .ToList(); } + finally { + LockObj.Release(); + } } - public (string vfsPath, string mainMediaFolderPath, IReadOnlyList<MediaFolderConfiguration> mediaList, bool skipGeneration) GetMediaFoldersForLibraryInVFS(Folder mediaFolder, CollectionType? collectionType, Func<MediaFolderConfiguration, bool>? filter = null) + public async Task<(string vfsPath, string mainMediaFolderPath, IReadOnlyList<MediaFolderConfiguration> mediaList, bool skipGeneration)> GetMediaFoldersForLibraryInVFS(Folder mediaFolder, CollectionType? collectionType, Func<MediaFolderConfiguration, bool>? filter = null) { - var mediaFolderConfig = GetOrCreateConfigurationForMediaFolder(mediaFolder, collectionType); - lock (LockObj) { + var mediaFolderConfig = await GetOrCreateConfigurationForMediaFolder(mediaFolder, collectionType); + await LockObj.WaitAsync(); + try { var skipGeneration = LibraryEdits.Count is > 0 && LibraryManager.IsScanRunning; if (LibraryManager.GetItemById(mediaFolderConfig.LibraryId) is not Folder libraryFolder) return (string.Empty, string.Empty, [], skipGeneration); @@ -249,11 +263,15 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) var mainMediaFolderPath = virtualFolder.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty; return (vfsPath, mainMediaFolderPath, mediaFolders, skipGeneration); } + finally { + LockObj.Release(); + } } - public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder mediaFolder, CollectionType? collectionType = CollectionType.unknown) + public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFolder(Folder mediaFolder, CollectionType? collectionType = CollectionType.unknown) { - lock (LockObj) { + await LockObj.WaitAsync(); + try { var allVirtualFolders = LibraryManager.GetVirtualFolders(); if (allVirtualFolders.FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path) && (collectionType is CollectionType.unknown || p.CollectionType.ConvertToCollectionType() == collectionType)) is not { } library || !Guid.TryParse(library.ItemId, out var libraryId)) throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); @@ -262,13 +280,16 @@ public MediaFolderConfiguration GetOrCreateConfigurationForMediaFolder(Folder me if (ShouldGenerateAllConfigurations) { ShouldGenerateAllConfigurations = false; - GenerateAllConfigurations(allVirtualFolders).GetAwaiter().GetResult(); + await GenerateAllConfigurations(allVirtualFolders).ConfigureAwait(false); } var config = Plugin.Instance.Configuration; var mediaFolderConfig = config.MediaFolders.First(c => c.MediaFolderId == mediaFolder.Id && c.LibraryId == libraryId); return mediaFolderConfig; } + finally { + LockObj.Release(); + } } private async Task GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualFolders) diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index 5e3f5101..f9957d4d 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -89,7 +89,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file var (mediaFolder, partialPath) = ApiManager.FindMediaFolder(fullPath, parent); // Ignore any media folders that aren't mapped to shoko. - var mediaFolderConfig = ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder); + var mediaFolderConfig = await ConfigurationService.GetOrCreateConfigurationForMediaFolder(mediaFolder); if (!mediaFolderConfig.IsMapped) { Logger.LogDebug("Skipped media folder for path {Path} (MediaFolder={MediaFolderId})", fileInfo.FullName, mediaFolderConfig.MediaFolderId); return false; diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 38fbd8b6..5fb85730 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -132,7 +132,7 @@ private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) /// <returns>The VFS path, if it succeeded.</returns> public async Task<(string?, bool)> GenerateStructureInVFS(Folder mediaFolder, CollectionType? collectionType, string path) { - var (vfsPath, mainMediaFolderPath, mediaConfigs, skipGeneration) = ConfigurationService.GetMediaFoldersForLibraryInVFS(mediaFolder, collectionType, config => config.IsVirtualFileSystemEnabled); + var (vfsPath, mainMediaFolderPath, mediaConfigs, skipGeneration) = await ConfigurationService.GetMediaFoldersForLibraryInVFS(mediaFolder, collectionType, config => config.IsVirtualFileSystemEnabled); if (string.IsNullOrEmpty(vfsPath) || string.IsNullOrEmpty(mainMediaFolderPath) || mediaConfigs.Count is 0) return (null, false); From e700a3ac9144f39979210cf52d6a7137550bcf5b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 02:40:32 +0200 Subject: [PATCH 112/144] fix: fix-up faulty config on start-up and save - Fix up any faulty configuration on start-up and when the plugin settings are saved. --- Shokofin/Plugin.cs | 34 +++++++++++++++++++++++++++++----- 1 file changed, 29 insertions(+), 5 deletions(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index 9abb440d..fde8d593 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -186,12 +186,15 @@ public Plugin(UsageTracker usageTracker, IServerConfigurationManager configurati } } + FixupConfiguration(Configuration); + IgnoredFolders = Configuration.IgnoredFolders.ToHashSet(); Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); Logger.LogDebug("Virtual File System Root Directory; {Path}", VirtualRoot); Logger.LogDebug("Can create symbolic links; {Value}", CanCreateSymbolicLinks); + // Disable VFS if we can't create symbolic links on Windows and no configuration exists. if (!configExists && !CanCreateSymbolicLinks) { Configuration.VFS_Enabled = false; SaveConfiguration(); @@ -207,16 +210,37 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) { if (e is not PluginConfiguration config) return; - if (string.IsNullOrWhiteSpace(config.VFS_CustomLocation) && config.VFS_CustomLocation is not null) { - config.VFS_CustomLocation = null; - SaveConfiguration(config); - } + + FixupConfiguration(config); + IgnoredFolders = config.IgnoredFolders.ToHashSet(); - Tracker.UpdateTimeout(TimeSpan.FromSeconds(Configuration.UsageTracker_StalledTimeInSeconds)); + Tracker.UpdateTimeout(TimeSpan.FromSeconds(config.UsageTracker_StalledTimeInSeconds)); + + // Reset the cached VFS root directory in case it has changed. _virtualRoot = null; + ConfigurationChanged?.Invoke(sender, config); } + public void FixupConfiguration(PluginConfiguration config) + { + // Fix-up faulty configuration. + var changed = false; + if (string.IsNullOrWhiteSpace(config.VFS_CustomLocation) && config.VFS_CustomLocation is not null) { + config.VFS_CustomLocation = null; + changed = true; + } + if (config.DescriptionSourceOrder.Length != Enum.GetValues<Text.DescriptionProvider>().Length) { + var current = config.DescriptionSourceOrder; + config.DescriptionSourceOrder = Enum.GetValues<Text.DescriptionProvider>() + .OrderBy(x => Array.IndexOf(current, x) == -1 ? int.MaxValue : Array.IndexOf(current, x)) + .ToArray(); + changed = true; + } + if (changed) + SaveConfiguration(config); + } + public HashSet<string> IgnoredFolders; #pragma warning disable 8618 From 365034f79ddd7b4ef0872c0f127952f1f9a524ce Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 2 Oct 2024 02:42:20 +0200 Subject: [PATCH 113/144] misc: demote desc selector - Demote the description selector from expert/advanced mode to not needing it. --- Shokofin/Pages/Settings.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 6404a16b..cd395c7d 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -247,7 +247,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of <span>Save</span> </button> </fieldset> - <fieldset id="Metadata_Description_Section" class="verticalSection verticalSection-extrabottompadding expert-only" hidden> + <fieldset id="Metadata_Description_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> <h3>Description Settings</h3> </legend> @@ -258,7 +258,7 @@ <h3>Description Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> </div> - <div id="DescriptionSourceList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> + <div id="DescriptionSourceList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Description source:</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="Shoko"> From f57c547e73233852f00741acc074c6296d757e83 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 5 Oct 2024 00:17:01 +0200 Subject: [PATCH 114/144] fix: fix `CollectionTypeOptions` to `CollectionType` conversion --- Shokofin/CollectionTypeExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/CollectionTypeExtensions.cs b/Shokofin/CollectionTypeExtensions.cs index 0593aa26..8cc1b3fc 100644 --- a/Shokofin/CollectionTypeExtensions.cs +++ b/Shokofin/CollectionTypeExtensions.cs @@ -16,7 +16,7 @@ public static class CollectionTypeExtensions CollectionTypeOptions.homevideos => CollectionType.homevideos, CollectionTypeOptions.boxsets => CollectionType.boxsets, CollectionTypeOptions.books => CollectionType.books, - CollectionTypeOptions.mixed => null, - null or _ => CollectionType.unknown, + null => null, + _ => CollectionType.unknown, }; } \ No newline at end of file From 6220224a67a688fa1c3031a60d15c505544c1786 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 5 Oct 2024 00:23:13 +0200 Subject: [PATCH 115/144] fix: fix faulty movie path generation check --- Shokofin/Resolvers/VirtualFileSystemService.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 5fb85730..2f55db7a 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -771,7 +771,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { var filePartSuffix = (episodeXref.Percentage?.Group ?? 1) is not 1 ? $".pt{episode.Shoko.CrossReferences.Where(xref => xref.ReleaseGroup == episodeXref.ReleaseGroup && xref.Percentage!.Group == episodeXref.Percentage!.Group).ToList().FindIndex(xref => xref.Percentage!.Start == episodeXref.Percentage!.Start && xref.Percentage!.End == episodeXref.Percentage!.End) + 1}" : ""; - if (collectionType is CollectionType.movies && !config.FilterMovieLibraries || isMovieSeason && collectionType is null) { + if (collectionType is CollectionType.movies || (collectionType is null && isMovieSeason)) { if (extrasFolders != null) { foreach (var extrasFolder in extrasFolders) foreach (var episodeInfo in season.EpisodeList.Where(a => a.Shoko.Size > 0)) From 2c2ec46ba41f8e7558aca57a789d89216633fe73 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 5 Oct 2024 01:01:57 +0200 Subject: [PATCH 116/144] chore: more DRY code - Consolidate code paths to only generate the list of all possible VFS roots in one place, then re-use the generated list elsewhere. --- .../MediaFolderConfigurationService.cs | 7 +----- Shokofin/Plugin.cs | 23 +++++++++++++++---- Shokofin/Tasks/CleanupVirtualRootTask.cs | 7 +----- 3 files changed, 21 insertions(+), 16 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 3e3ed887..2718b6d4 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -337,14 +337,9 @@ private async Task GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualF edits.add.Add(vfsPath); } - var virtualRoots = new string[] { - Plugin.Instance.VirtualRoot_Default, - Plugin.Instance.VirtualRoot_Cache, - Plugin.Instance.VirtualRoot_Custom ?? string.Empty, - }.Where(s => !string.IsNullOrEmpty(s)).ToArray(); var toRemove = virtualFolder.Locations .Except(shouldAttach ? [vfsPath] : []) - .Where(location => virtualRoots.Any(virtualRoot => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) + .Where(location => Plugin.Instance.AllVirtualRoots.Any(virtualRoot => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) .ToList(); if (toRemove.Count > 0) { if (!LibraryEdits.TryGetValue(libraryId, out var edits)) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index fde8d593..ff0dbd95 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -144,11 +144,25 @@ public string VirtualRoot } } - public string VirtualRoot_Default => Path.Join(ApplicationPaths.ProgramDataPath, Name); - - public string VirtualRoot_Cache => Path.Join(ApplicationPaths.CachePath, Name); + private string[]? _allVirtualRoots; - public string? VirtualRoot_Custom => string.IsNullOrWhiteSpace(Configuration.VFS_CustomLocation) ? null : Path.Combine(ApplicationPaths.ProgramDataPath, Configuration.VFS_CustomLocation); + /// <summary> + /// All "Virtual" File System Root Directories. + /// </summary> + public string[] AllVirtualRoots => _allVirtualRoots ??= (new string[] { + VirtualRoot_Default, + VirtualRoot_Cache, + VirtualRoot_Custom ?? string.Empty + }) + .Except([string.Empty]) + .Distinct() + .ToArray(); + + private string VirtualRoot_Default => Path.Join(ApplicationPaths.ProgramDataPath, Name); + + private string VirtualRoot_Cache => Path.Join(ApplicationPaths.CachePath, Name); + + private string? VirtualRoot_Custom => string.IsNullOrWhiteSpace(Configuration.VFS_CustomLocation) ? null : Path.Combine(ApplicationPaths.ProgramDataPath, Configuration.VFS_CustomLocation); /// <summary> /// Gets or sets the event handler that is triggered when this configuration changes. @@ -218,6 +232,7 @@ public void OnConfigChanged(object? sender, BasePluginConfiguration e) // Reset the cached VFS root directory in case it has changed. _virtualRoot = null; + _allVirtualRoots = null; ConfigurationChanged?.Invoke(sender, config); } diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index b73e6900..43f61538 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -56,12 +56,7 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat return Task.CompletedTask; var start = DateTime.Now; - var virtualRoots = new string[] { - Plugin.Instance.VirtualRoot_Default, - Plugin.Instance.VirtualRoot_Cache, - Plugin.Instance.VirtualRoot_Custom ?? string.Empty, - } - .Except([Plugin.Instance.VirtualRoot, string.Empty]) + var virtualRoots = Plugin.Instance.AllVirtualRoots .Where(Directory.Exists) .ToList(); Logger.LogDebug("Found {RemoveCount} VFS roots to remove.", virtualRoots.Count); From 221c54cdbacf79b7c0eff7461381009b1a78e081 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 5 Oct 2024 01:03:46 +0200 Subject: [PATCH 117/144] fix: don't scan _any_ of the VFS roots in the ignore rule --- Shokofin/Resolvers/ShokoIgnoreRule.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Resolvers/ShokoIgnoreRule.cs b/Shokofin/Resolvers/ShokoIgnoreRule.cs index f9957d4d..1c6bcc10 100644 --- a/Shokofin/Resolvers/ShokoIgnoreRule.cs +++ b/Shokofin/Resolvers/ShokoIgnoreRule.cs @@ -65,7 +65,7 @@ public async Task<bool> ShouldFilterItem(Folder? parent, FileSystemMetadata file return false; // Assume anything within the VFS is already okay. - if (fileInfo.FullName.StartsWith(Plugin.Instance.VirtualRoot)) + if (Plugin.Instance.AllVirtualRoots.Any(fileInfo.FullName.StartsWith)) return false; Guid? trackerId = null; From df1a57b62d6e07267b54f2fb27ac0118abdd6423 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sat, 5 Oct 2024 23:19:10 +0200 Subject: [PATCH 118/144] fix: fix help link per tab --- Shokofin/Pages/Scripts/Common.js | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/Shokofin/Pages/Scripts/Common.js b/Shokofin/Pages/Scripts/Common.js index ace81cd3..8fa93901 100644 --- a/Shokofin/Pages/Scripts/Common.js +++ b/Shokofin/Pages/Scripts/Common.js @@ -577,7 +577,7 @@ export function updateTabs(view, tabName) { LibraryMenu.setTabs("shoko", index, () => tabs); - const helpLink = view.querySelector(".sectionTitleContainer > a.helpLink"); + const helpLink = view.querySelector(".sectionTitleContainer > a.headerHelpButton"); if (helpLink) { const currentTab = Tabs.find(tab => tab.id === State.currentTab); if (currentTab) { @@ -868,8 +868,8 @@ export function setupEvents(view, events, initialTab = "connection", hide = fals const eventDetails = { /** @type {MinimalDetails} */ detail: { - type: event.detail.type, - properties: event.detail.properties, + type: view.getAttribute("data-type") || null, + properties: (view.getAttribute("data-properties") || "").split(","), }, bubbles: true, cancelable: false, From 6929250858944b4841cf4e813e3bfe56fa44fec6 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 01:09:54 +0200 Subject: [PATCH 119/144] fix: ensure VFS roots exist at startup MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Ensure the physical VFS roots exist at startup in case the user for some reason decided to delete them… causing library scans to no longer work until they're re-added. --- Shokofin/Tasks/CleanupVirtualRootTask.cs | 33 +++++++++++++++++++++--- 1 file changed, 30 insertions(+), 3 deletions(-) diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index 43f61538..2f1f7194 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -4,6 +4,8 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Library; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.Logging; @@ -14,7 +16,7 @@ namespace Shokofin.Tasks; /// <summary> /// Clean-up any old VFS roots leftover from an outdated install or failed removal of the roots. /// </summary> -public class CleanupVirtualRootTask(ILogger<CleanupVirtualRootTask> logger, IFileSystem fileSystem, LibraryScanWatcher scanWatcher) : IScheduledTask, IConfigurableScheduledTask +public class CleanupVirtualRootTask(ILogger<CleanupVirtualRootTask> logger, ILibraryManager libraryManager, IFileSystem fileSystem, LibraryScanWatcher scanWatcher) : IScheduledTask, IConfigurableScheduledTask { /// <inheritdoc /> public string Name => "Clean-up Virtual File System Roots"; @@ -39,6 +41,8 @@ public class CleanupVirtualRootTask(ILogger<CleanupVirtualRootTask> logger, IFil private readonly ILogger<CleanupVirtualRootTask> Logger = logger; + private readonly ILibraryManager LibraryManager = libraryManager; + private readonly IFileSystem FileSystem = fileSystem; private readonly LibraryScanWatcher ScanWatcher = scanWatcher; @@ -68,12 +72,12 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat Logger.LogTrace("Removed VFS root {Path} in {TimeSpan}.", virtualRoot, perFolderDeltaTime); } - var mediaFolders = Plugin.Instance.Configuration.MediaFolders.ToList() + var libraryIds = Plugin.Instance.Configuration.MediaFolders.ToList() .Select(config => config.LibraryId.ToString()) .Distinct() .ToList(); var vfsRoots = FileSystem.GetDirectories(Plugin.Instance.VirtualRoot, false) - .ExceptBy(mediaFolders, directoryInfo => directoryInfo.Name) + .ExceptBy(libraryIds, directoryInfo => directoryInfo.Name) .ToList(); Logger.LogDebug("Found {RemoveCount} VFS library roots to remove.", vfsRoots.Count); foreach (var vfsRoot in vfsRoots) { @@ -87,6 +91,29 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat var deltaTime = DateTime.Now - start; Logger.LogDebug("Removed {RemoveCount} VFS roots in {TimeSpan}.", vfsRoots.Count, deltaTime); + if (Plugin.Instance.Configuration.VFS_AttachRoot) { + start = DateTime.Now; + var addedCount = 0; + var vfsPaths = Plugin.Instance.Configuration.MediaFolders + .Select(config => LibraryManager.GetItemById(config.LibraryId) as Folder) + .Where(folder => folder is not null) + .Select(folder => folder!.GetVirtualRoot()) + .ToList(); + Logger.LogDebug("Ensuring {TotalCount} VFS roots exist.", vfsPaths.Count); + foreach (var vfsPath in vfsPaths) { + var folderStart = DateTime.Now; + if (Directory.Exists(vfsPath)) + continue; + + addedCount++; + Directory.CreateDirectory(vfsPath); + Logger.LogTrace("Added VFS root: {Path}", vfsPath); + } + + deltaTime = DateTime.Now - start; + Logger.LogDebug("Added {AddedCount} missing VFS roots in {TimeSpan}.", addedCount, deltaTime); + } + return Task.CompletedTask; } } From 0d636e6efca044051ab089efe67cd97f2a7bb7b7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 01:10:10 +0200 Subject: [PATCH 120/144] chore: js style fixes --- Shokofin/Pages/Scripts/Common.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/Shokofin/Pages/Scripts/Common.js b/Shokofin/Pages/Scripts/Common.js index 8fa93901..93a43242 100644 --- a/Shokofin/Pages/Scripts/Common.js +++ b/Shokofin/Pages/Scripts/Common.js @@ -570,10 +570,12 @@ export function updateTabs(view, tabName) { if (tabName) { State.currentTab = tabName; } + const tabs = Tabs.filter(tab => tab.id === State.currentTab || (tab.connected === undefined || tab.connected === State.connected) && (tab.expertMode === undefined || tab.expertMode === State.expertMode)); let index = tabs.findIndex((tab => tab.id === State.currentTab)); - if (index === -1) + if (index === -1) { index = 0; + } LibraryMenu.setTabs("shoko", index, () => tabs); From 6829d749d82967dd6bda47199905c9dea7d1469a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 01:56:40 +0200 Subject: [PATCH 121/144] fix: ensureVFS roots are not empty at startup - Ensure the physical VFS roots are not empty at startup to prevent Jellyfin from skipping them during a library scan. - Only check the same VFS root once. --- Shokofin/Tasks/CleanupVirtualRootTask.cs | 25 ++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index 2f1f7194..c74171b7 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -94,24 +94,33 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat if (Plugin.Instance.Configuration.VFS_AttachRoot) { start = DateTime.Now; var addedCount = 0; + var fixedCount = 0; var vfsPaths = Plugin.Instance.Configuration.MediaFolders + .DistinctBy(config => config.LibraryId) .Select(config => LibraryManager.GetItemById(config.LibraryId) as Folder) .Where(folder => folder is not null) .Select(folder => folder!.GetVirtualRoot()) .ToList(); Logger.LogDebug("Ensuring {TotalCount} VFS roots exist.", vfsPaths.Count); foreach (var vfsPath in vfsPaths) { - var folderStart = DateTime.Now; - if (Directory.Exists(vfsPath)) - continue; - - addedCount++; - Directory.CreateDirectory(vfsPath); - Logger.LogTrace("Added VFS root: {Path}", vfsPath); + // For Jellyfin to successfully scan the library we need to + // a) make sure it exists so we can add it without Jellyfin throwing a fit, and + // b) make sure it's not empty to make sure Jellyfin doesn't skip resolving it. + if (!Directory.Exists(vfsPath)) { + addedCount++; + Directory.CreateDirectory(vfsPath); + File.WriteAllText(Path.Join(vfsPath, ".keep"), string.Empty); + Logger.LogTrace("Added VFS root: {Path}", vfsPath); + } + else if (!FileSystem.GetFileSystemEntryPaths(vfsPath).Any()) { + fixedCount++; + File.WriteAllText(Path.Join(vfsPath, ".keep"), string.Empty); + Logger.LogTrace("Fixed VFS root: {Path}", vfsPath); + } } deltaTime = DateTime.Now - start; - Logger.LogDebug("Added {AddedCount} missing VFS roots in {TimeSpan}.", addedCount, deltaTime); + Logger.LogDebug("Added {AddedCount} missing and fixed {FixedCount} broken VFS roots in {TimeSpan}.", addedCount, fixedCount, deltaTime); } return Task.CompletedTask; From 7344b332f2ef172e651bb99a8951d7aaafc89e85 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 02:09:42 +0200 Subject: [PATCH 122/144] misc: remove `.keep` file when it's not needed anymore --- Shokofin/Resolvers/ShokoResolver.cs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/Shokofin/Resolvers/ShokoResolver.cs b/Shokofin/Resolvers/ShokoResolver.cs index 27906749..1c7b1199 100644 --- a/Shokofin/Resolvers/ShokoResolver.cs +++ b/Shokofin/Resolvers/ShokoResolver.cs @@ -212,10 +212,17 @@ NamingOptions namingOptions Logger.LogTrace(ex, "Failed to remove {Path}", pathToRemove); } } + var deltaTime = DateTime.Now - start; Logger.LogDebug("Cleaned up {Count} removed entries in {Time}", pathsToRemove.Count, deltaTime); } + var keepFile = Path.Join(vfsPath, ".keep"); + if (File.Exists(keepFile)) { + Logger.LogTrace("Removing now unneeded keep file: {Path}", keepFile); + File.Delete(keepFile); + } + // TODO: uncomment the code snippet once we reach JF 10.10. // return new() { Items = items, ExtraFiles = new() }; From 2d3d7ca4335c74e5b110eb788feb341c49f125ad Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 04:08:59 +0200 Subject: [PATCH 123/144] misc: add expert/advanced mode visual indicator --- Shokofin/Pages/Settings.html | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index cd395c7d..63adbce5 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -1,7 +1,28 @@ <div data-role="page" class="page type-interior pluginConfigurationPage withTabs" data-require="emby-input,emby-button,emby-select,emby-checkbox" data-controller="__plugin/Shoko.Settings.js"> <div data-role="content"> <div class="content-primary"> - <style>form:not(.expert-mode) .expert-only { display: none !important; }</style> + <style> +form:not(.expert-mode) .expert-only { + display: none !important; +} + +.expert-only.inputContainer .inputLabel::after, +.expert-only.selectContainer .selectLabel::after, +.expert-only.checkboxContainer .checkboxLabel::after, +.expert-only[is="checkbox-list"] .checkboxListLabel::after, +.expert-only[is="sortable-checkbox-list"] .checkboxListLabel::after { + font-size: 50%; + color: red; + margin-inline-start: 0.25rem; + padding-inline: 0.25rem; + vertical-align: middle; + content: "ADVANCED"; + border: red 1px solid; + border-radius: 1rem; + display: "inline"; +} + +</style> <form> <div class="verticalSection verticalSection-extrabottompadding"> <div class="sectionTitleContainer flex align-items-center"> From 0fc505448180b00e7bd7efdee69f062436a0e61a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 04:10:31 +0200 Subject: [PATCH 124/144] chore: remove colons from option names --- Shokofin/Pages/Settings.html | 70 ++++++++++++++++++------------------ 1 file changed, 35 insertions(+), 35 deletions(-) diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 63adbce5..baef190d 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -34,20 +34,20 @@ <h2 class="sectionTitle">Shoko</h2> <h3>Connection Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Url" label="Private Host Url:" /> + <input is="emby-input" type="text" id="Url" label="Private Host Url" /> <div class="fieldDescription">This is the private URL leading to where Shoko is running. It will be used internally in Jellyfin and also for all images sent to clients and redirects back to the Shoko instance if you don't set a public host URL below. It <i>should</i> include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="PublicUrl" label="Public Host Url:" /> + <input is="emby-input" type="text" id="PublicUrl" label="Public Host Url" /> <div class="fieldDescription">Optional. This is the public URL leading to where Shoko is running. It can be used to redirect to Shoko if you click on a Shoko ID in the UI if Shoko and/or Jellyfin is running within a container and you cannot access Shoko from the host URL provided in the connection settings section above. It will also be used for images from the plugin when viewing the "Edit Images" modal in clients. It <i>should</i> include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Username" label="Username:" /> + <input is="emby-input" type="text" id="Username" label="Username" /> <div class="fieldDescription">The user should be an administrator in Shoko, preferably without any filtering applied.</div> </div> <div id="ConnectionSetContainer"> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="password" id="Password" label="Password:" /> + <input is="emby-input" type="password" id="Password" label="Password" /> <div class="fieldDescription">The password for account. It can be empty.</div> </div> <button is="emby-button" type="submit" name="establish-connection" class="raised button-submit block emby-button"> @@ -57,7 +57,7 @@ <h3>Connection Settings</h3> </div> <div id="ConnectionResetContainer" hidden> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="ServerVersion" label="Server Version:" readonly value="Unknown Version" /> + <input is="emby-input" type="text" id="ServerVersion" label="Server Version" readonly value="Unknown Version" /> <div class="fieldDescription">The version of Shoko Server we're connected to.</div> </div> <button is="emby-button" type="submit" name="reset-connection" class="raised block emby-button"> @@ -71,7 +71,7 @@ <h3>Connection Settings</h3> <h3>Title Settings</h3> </legend> <div id="TitleMainList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Main title source:</h3> + <h3 class="checkboxListLabel">Main title source</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> @@ -161,7 +161,7 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of <div class="fieldDescription">The metadata providers to use as the source of the main title, in priority order.</div> </div> <div id="TitleAlternateList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Alternate/original title source:</h3> + <h3 class="checkboxListLabel">Alternate/original title source</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> @@ -280,7 +280,7 @@ <h3>Description Settings</h3> <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> </div> <div id="DescriptionSourceList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Description source:</h3> + <h3 class="checkboxListLabel">Description source</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="Shoko"> <label class="listItemCheckboxContainer"> @@ -351,7 +351,7 @@ <h3>Tag & Genre Settings</h3> <div class="fieldDescription checkboxFieldDescription">Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.</div> </div> <div id="TagSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Tag sources:</h3> + <h3 class="checkboxListLabel">Tag sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -582,7 +582,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> <div class="fieldDescription">The tag sources to use as the source of tags.</div> </div> <div id="TagIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Tag include filters:</h3> + <h3 class="checkboxListLabel">Tag include filters</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -651,7 +651,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <div class="fieldDescription">The type of tags to include for tags.</div> </div> <div class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="TagMinimumWeight">Minimum tag weight for tags:</label> + <label class="selectLabel" for="TagMinimumWeight">Minimum tag weight for tags</label> <select is="emby-select" id="TagMinimumWeight" name="TagMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless" selected>Disabled (Default)</option> <option value="One">⯪☆☆</option> @@ -666,11 +666,11 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> </div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" id="TagMaximumDepth" label="Maximum depth to add per tag source:" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> + <input is="emby-input" id="TagMaximumDepth" label="Maximum depth to add per tag source" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for tags.</div> </div> <div id="GenreSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Genre sources:</h3> + <h3 class="checkboxListLabel">Genre sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -901,7 +901,7 @@ <h3 class="listItemBodyText">Custom User Tags</h3> <div class="fieldDescription">The tag sources to use as the source of genres.</div> </div> <div id="GenreIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Genre include filters:</h3> + <h3 class="checkboxListLabel">Genre include filters</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -970,7 +970,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <div class="fieldDescription">The type of tags to include for genres.</div> </div> <div class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="GenreMinimumWeight">Minimum tag weight for genres:</label> + <label class="selectLabel" for="GenreMinimumWeight">Minimum tag weight for genres</label> <select is="emby-select" id="GenreMinimumWeight" name="GenreMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless">Disabled</option> <option value="One">⯪☆☆</option> @@ -985,7 +985,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> </div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" id="GenreMaximumDepth" label="Maximum depth to add per genre source:" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> + <input is="emby-input" id="GenreMaximumDepth" label="Maximum depth to add per genre source" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for genres.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> @@ -1019,7 +1019,7 @@ <h3>Image Settings</h3> <h3>Miscellaneous Settings</h3> </legend> <div id="ContentRatingList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Content rating sources:</h3> + <h3 class="checkboxListLabel">Content rating sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> @@ -1049,7 +1049,7 @@ <h3 class="listItemBodyText">TMDB | Follow country/region set in library</h3> <div class="fieldDescription">The metadata providers to use as the source of content ratings, in priority order.</div> </div> <div id="ProductionLocationList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Production location sources:</h3> + <h3 class="checkboxListLabel">Production location sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> @@ -1087,7 +1087,7 @@ <h3 class="listItemBodyText">TMDB</h3> <h3>Third Party Integration</h3> </legend> <div id="ThirdPartyIdProviderList" is="checkbox-list" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Add Third Party IDs:</h3> + <h3 class="checkboxListLabel">Add Third Party IDs</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -1176,7 +1176,7 @@ <h3>Basic Settings</h3> <div class="fieldDescription checkboxFieldDescription">This filters out anything that is not a movie in any movie libraries. Disable this if you want your anything to show up as movies in your movie libraries instead.</div> </div> <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription expert-only" hidden> - <label class="selectLabel" for="SeasonOrdering">Season ordering:</label> + <label class="selectLabel" for="SeasonOrdering">Season ordering</label> <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> <option value="Default" selected>Let Shoko decide</option> <option value="ReleaseDate">Order seasons by release date</option> @@ -1186,7 +1186,7 @@ <h3>Basic Settings</h3> <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="SpecialsPlacement">Specials placement within seasons:</label> + <label class="selectLabel" for="SpecialsPlacement">Specials placement within seasons</label> <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> <option value="AfterSeason">Always place specials after the normal episodes (Default)</option> <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> @@ -1219,7 +1219,7 @@ <h3>Basic Settings</h3> <h3>Collection Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="CollectionGrouping">Collections:</label> + <label class="selectLabel" for="CollectionGrouping">Collections</label> <select is="emby-select" id="CollectionGrouping" name="CollectionGrouping" class="emby-select-withcolor emby-select"> <option value="None" selected>Do not create collections</option> <option value="Movies">Create collections for movies based upon Shoko's series</option> @@ -1283,7 +1283,7 @@ <h3>New Library Settings</h3> </div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="LibraryFilteringMode">Legacy Library Filtering:</label> + <label class="selectLabel" for="LibraryFilteringMode">Legacy Library Filtering</label> <select is="emby-select" id="LibraryFilteringMode" name="LibraryFilteringMode" class="emby-select-withcolor emby-select"> <option value="Auto">Auto</option> <option value="Strict" selected>Strict</option> @@ -1314,7 +1314,7 @@ <h3>New Library Settings</h3> <h3>Existing Library Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="MediaFolderSelector">Configure settings for:</label> + <label class="selectLabel" for="MediaFolderSelector">Configure settings for</label> <select is="emby-select" id="MediaFolderSelector" name="MediaFolderSelector" value="" class="emby-select-withcolor emby-select"> <option value="">Click here to select a library</option> </select> @@ -1322,7 +1322,7 @@ <h3>Existing Library Settings</h3> </div> <div id="MediaFolderPerFolderSettingsContainer" hidden> <div id="MediaFolderImportFolderMapping" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Import Folder Mapping:</h3> + <h3 class="checkboxListLabel">Import Folder Mapping</h3> <div class="checkboxList paperList checkboxList-paperList"> </div> <div class="fieldDescription">The Shoko Import Folders the Media Folders are mapped to.</div> @@ -1344,7 +1344,7 @@ <h3 class="checkboxListLabel">Import Folder Mapping:</h3> </div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="MediaFolderLibraryFilteringMode">Legacy Library Filtering:</label> + <label class="selectLabel" for="MediaFolderLibraryFilteringMode">Legacy Library Filtering</label> <select is="emby-select" id="MediaFolderLibraryFilteringMode" name="MediaFolderLibraryFilteringMode" class="emby-select-withcolor emby-select"> <option value="Auto">Auto</option> <option value="Strict" selected>Strict</option> @@ -1458,7 +1458,7 @@ <h3>VFS Location Settings</h3> <div class="fieldDescription checkboxFieldDescription">Enabling this will attach a physical VFS "root" to your libraries as a media folder, allowing us to mount the VFS children to the new "root" and to re-use media folders across libraries without resorting to <i>other</i> workarounds.</div> </div> <div id="VFS_LocationContainer" class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="VFS_Location">VFS Location:</label> + <label class="selectLabel" for="VFS_Location">VFS Location</label> <select is="emby-select" id="VFS_Location" name="VFS_Location" class="emby-select-withcolor emby-select"> <option value="Default" selected>Jellyfin Data Directory (Default)</option> <option value="Cache">Jellyfin Cache Directory</option> @@ -1467,7 +1467,7 @@ <h3>VFS Location Settings</h3> <div class="fieldDescription">Determines where the VFS root will be placed. Changing this will cause your library to "remove" and "re-add" itself because of the path changes. You will need to manually move your VFS root if you plan to keep it when toggling this setting, or you can get rid of it by running the scheduled task to remove it. <strong>You have been warned.</strong></div> </div> <div id="VFS_CustomLocationContainer" class="inputContainer inputContainer-withDescription expert-only" hidden> - <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location:" disabled /> + <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location" disabled /> <div class="fieldDescription">An absolute path to the custom root directory of where the VFS will be placed. You decide.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> @@ -1479,7 +1479,7 @@ <h3>VFS Location Settings</h3> <h3>Connection Status</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="SignalRStatus" label="Connection Status:" disabled readonly value="Inactive"> + <input is="emby-input" type="text" id="SignalRStatus" label="Connection Status" disabled readonly value="Inactive"> <div class="fieldDescription">SignalR connection status.</div> </div> <div id="SignalRConnectContainer" hidden> @@ -1509,7 +1509,7 @@ <h3>Basic Settings</h3> </div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" type="text" id="SignalRAutoReconnectIntervals" label="Auto Reconnect Intervals:" /> + <input is="emby-input" type="text" id="SignalRAutoReconnectIntervals" label="Auto Reconnect Intervals" /> <div class="fieldDescription">A comma separated list of intervals in seconds to try re-establish the connection if the plugin gets disconnected from Shoko Server.</div> </div> <div id="SignalREventSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> @@ -1580,7 +1580,7 @@ <h3>New Library Settings</h3> <h3>Existing Library Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="SignalRMediaFolderSelector">Configure settings for:</label> + <label class="selectLabel" for="SignalRMediaFolderSelector">Configure settings for</label> <select is="emby-select" id="SignalRMediaFolderSelector" name="SignalRMediaFolderSelector" value="" class="emby-select-withcolor emby-select"> <option value="">Click here to select a library</option> </select> @@ -1615,7 +1615,7 @@ <h3>Existing Library Settings</h3> <h3>User Settings</h3> </legend> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="UserSelector">Configure settings for:</label> + <label class="selectLabel" for="UserSelector">Configure settings for</label> <select is="emby-select" id="UserSelector" name="UserSelector" value="" class="emby-select-withcolor emby-select"> <option value="">Click here to select a user</option> </select> @@ -1672,11 +1672,11 @@ <h3>User Settings</h3> <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko for restricted videos (H).</div> </div> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="UserUsername" label="Username:" /> + <input is="emby-input" type="text" id="UserUsername" label="Username" /> <div class="fieldDescription">The username of the account to synchronize with the currently selected user.</div> </div> <div id="UserPasswordContainer" class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="password" id="UserPassword" label="Password:" /> + <input is="emby-input" type="password" id="UserPassword" label="Password" /> <div class="fieldDescription">The password for account. It can be empty.</div> </div> <div id="UserDeleteContainer" class="inputContainer inputContainer-withDescription" hidden> @@ -1702,7 +1702,7 @@ <h3>Miscellaneous Settings</h3> <div class="fieldDescription checkboxFieldDescription">Shows a shortcut to the plugin in the sidebar navigation menu.</div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names:" /> + <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names" /> <div class="fieldDescription">A comma separated list of folder names which will be ignored during library filtering.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> From e42173fec932440d28e9cdeab33cbe44fee41d54 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 06:02:34 +0200 Subject: [PATCH 125/144] misc: move season ordering back --- Shokofin/Pages/Settings.html | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index baef190d..8b521f4b 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -1161,6 +1161,16 @@ <h3>Basic Settings</h3> </details> </div> </div> + <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription expert-only" hidden> + <label class="selectLabel" for="SeasonOrdering">Season ordering</label> + <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> + <option value="Default" selected>Let Shoko decide</option> + <option value="ReleaseDate">Order seasons by release date</option> + <option value="Chronological" class="expert-only">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> + <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> + </select> + <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> + </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SeparateMovies" /> @@ -1175,16 +1185,6 @@ <h3>Basic Settings</h3> </label> <div class="fieldDescription checkboxFieldDescription">This filters out anything that is not a movie in any movie libraries. Disable this if you want your anything to show up as movies in your movie libraries instead.</div> </div> - <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription expert-only" hidden> - <label class="selectLabel" for="SeasonOrdering">Season ordering</label> - <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> - <option value="Default" selected>Let Shoko decide</option> - <option value="ReleaseDate">Order seasons by release date</option> - <option value="Chronological" class="expert-only">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> - <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> - </select> - <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> - </div> <div class="selectContainer selectContainer-withDescription"> <label class="selectLabel" for="SpecialsPlacement">Specials placement within seasons</label> <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> From 946b341e47ac7bddbed5e68bf4435d090b87ea2c Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 06:25:36 +0200 Subject: [PATCH 126/144] misc: update documentation links ahead of the new docs being launched --- Shokofin/Pages/Scripts/Common.js | 18 +++++++++--------- Shokofin/Pages/Settings.html | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Shokofin/Pages/Scripts/Common.js b/Shokofin/Pages/Scripts/Common.js index 93a43242..aab4688f 100644 --- a/Shokofin/Pages/Scripts/Common.js +++ b/Shokofin/Pages/Scripts/Common.js @@ -497,7 +497,7 @@ export const State = window["SHOKO_STATE_OBJECT"] || (window["SHOKO_STATE_OBJECT * @property {boolean?} expertMode Optional. Whether the tab is only rendered when in or not in expert mode. */ -const DefaultHelpLink = "https://docs.shokoanime.com/shokofin/configuration/"; +const DefaultHelpLink = "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/"; /** * @type {readonly ShokoTab[]} @@ -506,48 +506,48 @@ const Tabs = [ { id: "connection", href: getConfigurationPageUrl("Shoko.Settings", "connection"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#connection", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#connecting-to-shoko-server", name: "Connection", }, { id: "metadata", href: getConfigurationPageUrl("Shoko.Settings", "metadata"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#metadata", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#metadata", name: "Metadata", connected: true, }, { id: "library", href: getConfigurationPageUrl("Shoko.Settings", "library"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#library", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#library", name: "Library", connected: true, }, { id: "vfs", href: getConfigurationPageUrl("Shoko.Settings", "vfs"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#vfs", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#vfs", name: "VFS", connected: true, }, { id: "users", href: getConfigurationPageUrl("Shoko.Settings", "users"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#users", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#users", name: "Users", connected: true, }, { id: "signalr", href: getConfigurationPageUrl("Shoko.Settings", "signalr"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#signalr", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#signalr", name: "SignalR", connected: true, }, { id: "misc", href: getConfigurationPageUrl("Shoko.Settings", "misc"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#misc", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#misc", name: "Misc", connected: true, expertMode: true, @@ -555,7 +555,7 @@ const Tabs = [ { id: "utilities", href: getConfigurationPageUrl("Shoko.Settings", "utilities"), - helpHref: "https://docs.shokoanime.com/shokofin/configuration/#utilities", + helpHref: "https://docs.shokoanime.com/jellyfin-integration/utilities", name: "Utilities", }, ]; diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 8b521f4b..17b24f61 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -27,7 +27,7 @@ <div class="verticalSection verticalSection-extrabottompadding"> <div class="sectionTitleContainer flex align-items-center"> <h2 class="sectionTitle">Shoko</h2> - <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton emby-button" target="_blank" href="https://docs.shokoanime.com/shokofin/configuration/">Help</a> + <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton emby-button" target="_blank" href="https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/">Help</a> </div> <fieldset id="Connection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> From c09c6ea035e82c75d8429e5f929fd904926e1a15 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 06:43:38 +0200 Subject: [PATCH 127/144] misc: update `Disabled` to `All Allowed` for min. tag weights --- Shokofin/Pages/Settings.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 17b24f61..414e8192 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -653,7 +653,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <div class="selectContainer selectContainer-withDescription expert-only"> <label class="selectLabel" for="TagMinimumWeight">Minimum tag weight for tags</label> <select is="emby-select" id="TagMinimumWeight" name="TagMinimumWeight" class="emby-select-withcolor emby-select"> - <option value="Weightless" selected>Disabled (Default)</option> + <option value="Weightless" selected>All Allowed (Default)</option> <option value="One">⯪☆☆</option> <option value="Two">★☆☆</option> <option value="Three">★⯪☆</option> @@ -972,7 +972,7 @@ <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> <div class="selectContainer selectContainer-withDescription expert-only"> <label class="selectLabel" for="GenreMinimumWeight">Minimum tag weight for genres</label> <select is="emby-select" id="GenreMinimumWeight" name="GenreMinimumWeight" class="emby-select-withcolor emby-select"> - <option value="Weightless">Disabled</option> + <option value="Weightless">All Allowed</option> <option value="One">⯪☆☆</option> <option value="Two">★☆☆</option> <option value="Three" selected>★⯪☆</option> From edb38235b3b7bf5ff7a8ff83973a631349afece3 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Sun, 6 Oct 2024 07:15:39 +0200 Subject: [PATCH 128/144] fix: revert default vfs location back to the old location for now --- Shokofin/Plugin.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Plugin.cs b/Shokofin/Plugin.cs index ff0dbd95..09714c2b 100644 --- a/Shokofin/Plugin.cs +++ b/Shokofin/Plugin.cs @@ -158,7 +158,7 @@ public string VirtualRoot .Distinct() .ToArray(); - private string VirtualRoot_Default => Path.Join(ApplicationPaths.ProgramDataPath, Name); + private string VirtualRoot_Default => Path.Join(ApplicationPaths.ProgramDataPath, "Shokofin", "VFS"); private string VirtualRoot_Cache => Path.Join(ApplicationPaths.CachePath, Name); From 76dc21986a3b5231a84708347ece508f22305275 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 00:19:54 +0200 Subject: [PATCH 129/144] fix: correct episode ordering before creating specials anchors --- Shokofin/API/Info/SeasonInfo.cs | 11 ++++++++++- Shokofin/API/ShokoAPIManager.cs | 1 - 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/Shokofin/API/Info/SeasonInfo.cs b/Shokofin/API/Info/SeasonInfo.cs index 39f073d6..69db7e2d 100644 --- a/Shokofin/API/Info/SeasonInfo.cs +++ b/Shokofin/API/Info/SeasonInfo.cs @@ -129,6 +129,16 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext var episodesList = new List<EpisodeInfo>(); var extrasList = new List<EpisodeInfo>(); var altEpisodesList = new List<EpisodeInfo>(); + var seriesIdOrder = new string[] { seriesId }.Concat(extraIds).ToList(); + + // Order the episodes by date. + episodes = episodes + .OrderBy(episode => !episode.AniDB.AirDate.HasValue) + .ThenBy(episode => episode.AniDB.AirDate) + .ThenBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.ParentSeries.ToString())) + .ThenBy(episode => episode.AniDB.Type) + .ThenBy(episode => episode.AniDB.EpisodeNumber) + .ToList(); // Iterate over the episodes once and store some values for later use. int index = 0; @@ -172,7 +182,6 @@ public SeasonInfo(Series series, SeriesType? customType, IEnumerable<string> ext // We order the lists after sorting them into buckets because the bucket // sort we're doing above have the episodes ordered by air date to get // the previous episode anchors right. - var seriesIdOrder = new string[] { seriesId }.Concat(extraIds).ToList(); episodesList = episodesList .OrderBy(e => seriesIdOrder.IndexOf(e.Shoko.IDs.ParentSeries.ToString())) .ThenBy(e => e.AniDB.Type) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 776c06f9..2d5fc86d 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -746,7 +746,6 @@ private async Task<SeasonInfo> CreateSeasonInfo(Series series) .Select(id => APIClient.GetEpisodesFromSeries(id).ContinueWith(task => task.Result.List.Select(e => CreateEpisodeInfo(e, e.IDs.Shoko.ToString())))) ).ConfigureAwait(false)) .SelectMany(list => list) - .OrderBy(episode => episode.AniDB.AirDate) .ToList(); SeasonInfo seasonInfo; From 1414bb012f1744c26fcd7d0f87925fa6ca65afca Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 00:32:02 +0200 Subject: [PATCH 130/144] =?UTF-8?q?fix:=20don't=20remove=20the=20currently?= =?UTF-8?q?=20in=20use=20VFS=20root=20=F0=9F=A4=A6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Shokofin/Tasks/CleanupVirtualRootTask.cs | 1 + 1 file changed, 1 insertion(+) diff --git a/Shokofin/Tasks/CleanupVirtualRootTask.cs b/Shokofin/Tasks/CleanupVirtualRootTask.cs index c74171b7..d3bbf4e9 100644 --- a/Shokofin/Tasks/CleanupVirtualRootTask.cs +++ b/Shokofin/Tasks/CleanupVirtualRootTask.cs @@ -61,6 +61,7 @@ public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellat var start = DateTime.Now; var virtualRoots = Plugin.Instance.AllVirtualRoots + .Except([Plugin.Instance.VirtualRoot]) .Where(Directory.Exists) .ToList(); Logger.LogDebug("Found {RemoveCount} VFS roots to remove.", virtualRoots.Count); From fa261c55f26f6d21480d0c7ca8a6d93a5c6413c7 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 03:59:55 +0200 Subject: [PATCH 131/144] fix: fix episode parent series field usage - Added a temporary workaround for the stable server not supporting sending the parent series id with the episodes. It was added _15 days_ after the last stable. So I thought it was in, but apparently it wasn't. --- Shokofin/API/ShokoAPIClient.cs | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index 8e4a4ae9..c4e58a53 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -25,6 +25,11 @@ public class ShokoAPIClient : IDisposable private static ComponentVersion? ServerVersion => Plugin.Instance.Configuration.ServerVersion; + private static readonly DateTime EpisodeSeriesParentAddedDate = DateTime.Parse("2023-04-17T00:00:00.000Z"); + + private static bool UseEpisodeGetSeriesEndpoint => + ServerVersion != null && ((ServerVersion.ReleaseChannel == ReleaseChannel.Stable && ServerVersion.Version == new Version("4.2.2.0")) || (ServerVersion.ReleaseDate.HasValue && ServerVersion.ReleaseDate.Value < EpisodeSeriesParentAddedDate)); + private static readonly DateTime StableCutOffDate = DateTime.Parse("2023-12-16T00:00:00.000Z"); private static bool UseOlderSeriesAndFileEndpoints => @@ -348,7 +353,13 @@ public Task<Episode> GetEpisode(string id) // If the episode has no 'movie' images, get the series images to compensate. if (episodeImages.Posters.Count is 0) { var episode1 = await GetEpisode(id); - var seriesImages1 = await GetSeriesImages(episode1.IDs.ParentSeries.ToString()) ?? new(); + var seriesId = episode1.IDs.ParentSeries.ToString(); + if (UseEpisodeGetSeriesEndpoint) { + var series = await GetSeriesFromEpisode(id); + if (series != null) + seriesId = series.IDs.Shoko.ToString(); + } + var seriesImages1 = seriesId is not "0" ? await GetSeriesImages(seriesId) ?? new() : new(); episodeImages.Posters = seriesImages1.Posters; episodeImages.Logos = seriesImages1.Logos; From 720e48ce38610d608efdeaa50d56b9d81ee69f06 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 04:26:59 +0200 Subject: [PATCH 132/144] fix: fix episode parent series field usage (take 2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Moved the check from code path A to code path B, because the check was a no-operation on code path A and should had been placed on code path B in the first place. 🤦 --- Shokofin/API/ShokoAPIClient.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Shokofin/API/ShokoAPIClient.cs b/Shokofin/API/ShokoAPIClient.cs index c4e58a53..0d556b06 100644 --- a/Shokofin/API/ShokoAPIClient.cs +++ b/Shokofin/API/ShokoAPIClient.cs @@ -353,13 +353,7 @@ public Task<Episode> GetEpisode(string id) // If the episode has no 'movie' images, get the series images to compensate. if (episodeImages.Posters.Count is 0) { var episode1 = await GetEpisode(id); - var seriesId = episode1.IDs.ParentSeries.ToString(); - if (UseEpisodeGetSeriesEndpoint) { - var series = await GetSeriesFromEpisode(id); - if (series != null) - seriesId = series.IDs.Shoko.ToString(); - } - var seriesImages1 = seriesId is not "0" ? await GetSeriesImages(seriesId) ?? new() : new(); + var seriesImages1 = await GetSeriesImages(episode1.IDs.ParentSeries.ToString()) ?? new(); episodeImages.Posters = seriesImages1.Posters; episodeImages.Logos = seriesImages1.Logos; @@ -370,7 +364,13 @@ public Task<Episode> GetEpisode(string id) } var episode0 = await GetEpisode(id); - var seriesImages0 = await GetSeriesImages(episode0.IDs.ParentSeries.ToString()) ?? new(); + var seriesId0 = episode0.IDs.ParentSeries.ToString(); + if (UseEpisodeGetSeriesEndpoint) { + var series = await GetSeriesFromEpisode(id); + if (series != null) + seriesId0 = series.IDs.Shoko.ToString(); + } + var seriesImages0 = seriesId0 is not "0" ? await GetSeriesImages(seriesId0) ?? new() : new(); return new() { Banners = seriesImages0.Banners, Backdrops = seriesImages0.Backdrops, From 7c53798e179909430169492b986a0273d178497a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 06:00:58 +0200 Subject: [PATCH 133/144] fix: use vfs path as main folder path in events when attaching roots --- .../Configuration/MediaFolderConfigurationService.cs | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 2718b6d4..259b1d89 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -214,21 +214,22 @@ private async void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventAr LockObj.Wait(); try { var virtualFolders = LibraryManager.GetVirtualFolders(); + var attachRoot = Plugin.Instance.Configuration.VFS_AttachRoot; return Plugin.Instance.Configuration.MediaFolders .Where(config => config.IsMapped && !config.IsVirtualRoot && (filter is null || filter(config)) && LibraryManager.GetItemById(config.MediaFolderId) is Folder) .GroupBy(config => config.LibraryId) .Select(groupBy => ( libraryFolder: LibraryManager.GetItemById(groupBy.Key) as Folder, virtualFolder: virtualFolders.FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == groupBy.Key), - mediaList: groupBy - .Where(config => LibraryManager.GetItemById(config.MediaFolderId) is Folder) - .ToList() as IReadOnlyList<MediaFolderConfiguration> + mediaList: groupBy.ToList() as IReadOnlyList<MediaFolderConfiguration> )) .Where(tuple => tuple.libraryFolder is not null && tuple.virtualFolder is not null && tuple.virtualFolder.Locations.Length is > 0 && tuple.mediaList.Count is > 0) .Select(tuple => ( vfsPath: tuple.libraryFolder!.GetVirtualRoot(), - mainMediaFolderPath: tuple.virtualFolder!.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty, - collectionType: LibraryManager.GetConfiguredContentType(tuple.libraryFolder!), + mainMediaFolderPath: attachRoot + ? tuple.libraryFolder!.GetVirtualRoot() + : tuple.virtualFolder!.Locations.FirstOrDefault(a => DirectoryService.IsAccessible(a)) ?? string.Empty, + collectionType: tuple.virtualFolder!.CollectionType.ConvertToCollectionType(), tuple.mediaList )) .Where(tuple => !string.IsNullOrEmpty(tuple.vfsPath) && !string.IsNullOrEmpty(tuple.mainMediaFolderPath)) From 82396773c161f8fc9111aa62c6ab72b61530b65b Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 06:01:36 +0200 Subject: [PATCH 134/144] misc: tweak exception thrown when unable to find library for media folder for the given collection type --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 259b1d89..7ee20a1f 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -275,8 +275,7 @@ public async Task<MediaFolderConfiguration> GetOrCreateConfigurationForMediaFold try { var allVirtualFolders = LibraryManager.GetVirtualFolders(); if (allVirtualFolders.FirstOrDefault(p => p.Locations.Contains(mediaFolder.Path) && (collectionType is CollectionType.unknown || p.CollectionType.ConvertToCollectionType() == collectionType)) is not { } library || !Guid.TryParse(library.ItemId, out var libraryId)) - throw new Exception($"Unable to find library to use for media folder \"{mediaFolder.Path}\""); - + throw new Exception($"Unable to find any library to use for media folder \"{mediaFolder.Path}\""); if (ShouldGenerateAllConfigurations) { From f88cc7885e402abf46dc8fcb8b8e27cd815a7c31 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 06:01:52 +0200 Subject: [PATCH 135/144] misc: rename variable --- Shokofin/FolderExtensions.cs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Shokofin/FolderExtensions.cs b/Shokofin/FolderExtensions.cs index 87aae4eb..bb5501e7 100644 --- a/Shokofin/FolderExtensions.cs +++ b/Shokofin/FolderExtensions.cs @@ -5,6 +5,6 @@ namespace Shokofin; public static class FolderExtensions { - public static string GetVirtualRoot(this Folder mediaFolder) - => Path.Join(Plugin.Instance.VirtualRoot, mediaFolder.Id.ToString()); + public static string GetVirtualRoot(this Folder libraryFolder) + => Path.Join(Plugin.Instance.VirtualRoot, libraryFolder.Id.ToString()); } \ No newline at end of file From 55fc933c693a8f1f370d6bec71fd3591dadcde0a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 06:02:21 +0200 Subject: [PATCH 136/144] chore: switch to primary constructor for api controllers --- Shokofin/Web/ShokoApiController.cs | 20 +++++++------------- Shokofin/Web/SignalRApiController.cs | 18 ++++++------------ 2 files changed, 13 insertions(+), 25 deletions(-) diff --git a/Shokofin/Web/ShokoApiController.cs b/Shokofin/Web/ShokoApiController.cs index 98a70e38..19772a3a 100644 --- a/Shokofin/Web/ShokoApiController.cs +++ b/Shokofin/Web/ShokoApiController.cs @@ -15,24 +15,18 @@ namespace Shokofin.Web; /// <summary> /// Shoko API Host Web Controller. /// </summary> +/// <remarks> +/// Initializes a new instance of the <see cref="ShokoApiController"/> class. +/// </remarks> +/// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> [ApiController] [Route("Plugin/Shokofin/Host")] [Produces(MediaTypeNames.Application.Json)] -public class ShokoApiController : ControllerBase +public class ShokoApiController(ILogger<ShokoApiController> logger, ShokoAPIClient apiClient) : ControllerBase { - private readonly ILogger<ShokoApiController> Logger; + private readonly ILogger<ShokoApiController> Logger = logger; - private readonly ShokoAPIClient APIClient; - - /// <summary> - /// Initializes a new instance of the <see cref="ShokoApiController"/> class. - /// </summary> - /// <param name="httpClientFactory">Instance of the <see cref="IHttpClientFactory"/> interface.</param> - public ShokoApiController(ILogger<ShokoApiController> logger, ShokoAPIClient apiClient) - { - Logger = logger; - APIClient = apiClient; - } + private readonly ShokoAPIClient APIClient = apiClient; /// <summary> /// Try to get the version of the server. diff --git a/Shokofin/Web/SignalRApiController.cs b/Shokofin/Web/SignalRApiController.cs index 4ae5636b..cc063412 100644 --- a/Shokofin/Web/SignalRApiController.cs +++ b/Shokofin/Web/SignalRApiController.cs @@ -13,23 +13,17 @@ namespace Shokofin.Web; /// <summary> /// Shoko SignalR Control Web Controller. /// </summary> +/// <remarks> +/// Initializes a new instance of the <see cref="SignalRApiController"/> class. +/// </remarks> [ApiController] [Route("Plugin/Shokofin/SignalR")] [Produces(MediaTypeNames.Application.Json)] -public class SignalRApiController : ControllerBase +public class SignalRApiController(ILogger<SignalRApiController> logger, SignalRConnectionManager connectionManager) : ControllerBase { - private readonly ILogger<SignalRApiController> Logger; + private readonly ILogger<SignalRApiController> Logger = logger; - private readonly SignalRConnectionManager ConnectionManager; - - /// <summary> - /// Initializes a new instance of the <see cref="SignalRApiController"/> class. - /// </summary> - public SignalRApiController(ILogger<SignalRApiController> logger, SignalRConnectionManager connectionManager) - { - Logger = logger; - ConnectionManager = connectionManager; - } + private readonly SignalRConnectionManager ConnectionManager = connectionManager; /// <summary> /// Get the current status of the connection to Shoko Server. From 9153c7301c70315b475f5f74d1283c071d67d26a Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 06:03:48 +0200 Subject: [PATCH 137/144] fix: don't try to remove VFS root for media folders - Don't try to remove VFS roots for the media folders anymore, since the roots are now attached to the library and not media folders within the libraries. --- .../Resolvers/VirtualFileSystemService.cs | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 2f55db7a..98b79e0c 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -83,13 +83,11 @@ NamingOptions namingOptions DataCache = new(logger, new() { ExpirationScanFrequency = TimeSpan.FromMinutes(25) }, new() { AbsoluteExpirationRelativeToNow = TimeSpan.FromHours(1), SlidingExpiration = TimeSpan.FromMinutes(15) }); NamingOptions = namingOptions; ExternalPathParser = new ExternalPathParser(namingOptions, localizationManager, MediaBrowser.Model.Dlna.DlnaProfileType.Subtitle); - LibraryManager.ItemRemoved += OnLibraryManagerItemRemoved; Plugin.Instance.Tracker.Stalled += OnTrackerStalled; } ~VirtualFileSystemService() { - LibraryManager.ItemRemoved -= OnLibraryManagerItemRemoved; Plugin.Instance.Tracker.Stalled -= OnTrackerStalled; DataCache.Dispose(); } @@ -103,25 +101,6 @@ public void Clear() DataCache.Clear(); } - #region Changes Tracking - - private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) - { - // Remove the VFS directory for any media library folders when they're removed. - var root = LibraryManager.RootFolder; - if (e.Item != null && root != null && e.Item != root && e.Item is CollectionFolder folder) { - var vfsPath = folder.GetVirtualRoot(); - DataCache.Remove($"should-skip-vfs-path:{vfsPath}"); - if (Directory.Exists(vfsPath)) { - Logger.LogInformation("Removing VFS directory for folder at {Path}", folder.Path); - Directory.Delete(vfsPath, true); - Logger.LogInformation("Removed VFS directory for folder at {Path}", folder.Path); - } - } - } - - #endregion - #region Generate Structure /// <summary> From 4baee92fb132b91948c9015bf9d3d41a2ac62894 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 06:15:45 +0200 Subject: [PATCH 138/144] feat: add vfs preview endpoint - Added a new VFS preview endpoint for debugging purposes. ~~A debug utility may or may not appear in the plugin soon-_ish_.~~ --- .../Resolvers/Models/LinkGenerationResult.cs | 7 + .../Resolvers/VirtualFileSystemService.cs | 184 +++++++++++++----- Shokofin/Web/Models/VfsLibraryPreview.cs | 67 +++++++ Shokofin/Web/UtilityApiController.cs | 49 +++++ 4 files changed, 255 insertions(+), 52 deletions(-) create mode 100644 Shokofin/Web/Models/VfsLibraryPreview.cs create mode 100644 Shokofin/Web/UtilityApiController.cs diff --git a/Shokofin/Resolvers/Models/LinkGenerationResult.cs b/Shokofin/Resolvers/Models/LinkGenerationResult.cs index 51d89070..ade9b8f5 100644 --- a/Shokofin/Resolvers/Models/LinkGenerationResult.cs +++ b/Shokofin/Resolvers/Models/LinkGenerationResult.cs @@ -11,6 +11,8 @@ public class LinkGenerationResult public ConcurrentBag<string> Paths { get; init; } = []; + public ConcurrentBag<string> RemovedPaths { get; init; } = []; + public int Total => TotalVideos + TotalSubtitles; @@ -81,10 +83,15 @@ public void Print(ILogger logger, string path) foreach (var path in b.Paths) a.Paths.Add(path); + var removedPaths = a.RemovedPaths; + foreach (var path in b.RemovedPaths) + removedPaths.Add(path); + return new() { CreatedAt = a.CreatedAt, Paths = paths, + RemovedPaths = removedPaths, CreatedVideos = a.CreatedVideos + b.CreatedVideos, FixedVideos = a.FixedVideos + b.FixedVideos, SkippedVideos = a.SkippedVideos + b.SkippedVideos, diff --git a/Shokofin/Resolvers/VirtualFileSystemService.cs b/Shokofin/Resolvers/VirtualFileSystemService.cs index 98b79e0c..7978461c 100644 --- a/Shokofin/Resolvers/VirtualFileSystemService.cs +++ b/Shokofin/Resolvers/VirtualFileSystemService.cs @@ -101,6 +101,52 @@ public void Clear() DataCache.Clear(); } + #region Preview Structure + + public async Task<(HashSet<string> filesBefore, HashSet<string> filesAfter, VirtualFolderInfo? virtualFolder, LinkGenerationResult? result, string vfsPath)> PreviewChangesForLibrary(Guid libraryId) + { + // Don't allow starting a preview if a library scan is running. + + var virtualFolders = LibraryManager.GetVirtualFolders(); + var selectedFolder = virtualFolders.FirstOrDefault(folder => Guid.TryParse(folder.ItemId, out var guid) && guid == libraryId); + if (selectedFolder is null) + return ([], [], null, null, string.Empty); + + if (LibraryManager.FindByPath(selectedFolder.Locations[0], true) is not Folder mediaFolder) + return ([], [], selectedFolder, null, string.Empty); + + var collectionType = selectedFolder.CollectionType.ConvertToCollectionType(); + var (vfsPath, _, mediaConfigs, _) = await ConfigurationService.GetMediaFoldersForLibraryInVFS(mediaFolder, collectionType, config => config.IsVirtualFileSystemEnabled); + if (string.IsNullOrEmpty(vfsPath) || mediaConfigs.Count is 0) + return ([], [], selectedFolder, null, string.Empty); + + if (LibraryManager.IsScanRunning) + return ([], [], selectedFolder, null, string.Empty); + + // Only allow the preview to run once per caching cycle. + return await DataCache.GetOrCreateAsync($"preview-changes:{vfsPath}", async () => { + var allPaths = GetPathsForMediaFolders(mediaConfigs); + var allFiles = GetFilesForImportFolders(mediaConfigs, allPaths); + var result = await GenerateStructure(collectionType, vfsPath, allFiles, preview: true); + result += CleanupStructure(vfsPath, vfsPath, result.Paths.ToArray(), preview: true); + + // This call will be slow depending on the size of your collection. + var existingPaths = FileSystem.DirectoryExists(vfsPath) + ? FileSystem.GetFilePaths(vfsPath, true).ToHashSet() + : []; + + // Alter the paths to match the new structure. + var alteredPaths = existingPaths + .Concat(result.Paths.ToArray()) + .Except(result.RemovedPaths.ToArray()) + .ToHashSet(); + + return (existingPaths, alteredPaths, selectedFolder, result, vfsPath); + }); + } + + #endregion + #region Generate Structure /// <summary> @@ -626,7 +672,7 @@ private HashSet<string> GetPathsForMediaFolders(IReadOnlyList<MediaFolderConfigu return await ApiClient.GetFilesForImportFolder(importFolderId, importFolderSubPath, page).ConfigureAwait(false); } - private async Task<LinkGenerationResult> GenerateStructure(CollectionType? collectionType, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles) + private async Task<LinkGenerationResult> GenerateStructure(CollectionType? collectionType, string vfsPath, IEnumerable<(string sourceLocation, string fileId, string seriesId)> allFiles, bool preview = false) { var result = new LinkGenerationResult(); var maxTotalExceptions = Plugin.Instance.Configuration.VFS_MaxTotalExceptionsBeforeAbort; @@ -652,7 +698,7 @@ await Task.WhenAll(allFiles.Select(async (tuple) => { if (symbolicLinks.Length == 0 || !importedAt.HasValue) return; - var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, importedAt.Value); + var subResult = GenerateSymbolicLinks(sourceLocation, symbolicLinks, importedAt.Value, preview); // Combine the current results with the overall results. lock (semaphore) { @@ -804,11 +850,11 @@ file.Shoko.AniDBData is not null return (symbolicLinks, (file.Shoko.ImportedAt ?? file.Shoko.CreatedAt).ToLocalTime()); } - public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt) + public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[] symbolicLinks, DateTime importedAt, bool preview = false) { try { var result = new LinkGenerationResult(); - if (Plugin.Instance.Configuration.VFS_ResolveLinks) { + if (Plugin.Instance.Configuration.VFS_ResolveLinks && !preview) { Logger.LogTrace("Attempting to resolve link for {Path}", sourceLocation); try { if (File.ResolveLinkTarget(sourceLocation, true) is { } linkTarget) { @@ -832,10 +878,12 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ result.Paths.Add(symbolicLink); if (!File.Exists(symbolicLink)) { result.CreatedVideos++; - Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - // Mock the creation date to fake the "date added" order in Jellyfin. - File.SetCreationTime(symbolicLink, importedAt); + if (!preview) { + Logger.LogDebug("Linking {Link} → {LinkTarget}", symbolicLink, sourceLocation); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + // Mock the creation date to fake the "date added" order in Jellyfin. + File.SetCreationTime(symbolicLink, importedAt); + } } else { var shouldFix = false; @@ -843,26 +891,29 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ var nextTarget = File.ResolveLinkTarget(symbolicLink, false); if (!string.Equals(sourceLocation, nextTarget?.FullName)) { shouldFix = true; - - Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); + if (!preview) + Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", symbolicLink, sourceLocation, nextTarget?.FullName); } var date = File.GetCreationTime(symbolicLink).ToLocalTime(); if (date != importedAt) { shouldFix = true; - - Logger.LogWarning("Fixing broken symbolic link {Link} with incorrect date.", symbolicLink); + if (!preview) + Logger.LogWarning("Fixing broken symbolic link {Link} with incorrect date.", symbolicLink); } } catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicLink); shouldFix = true; + if (!preview) + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link}", symbolicLink); } if (shouldFix) { - File.Delete(symbolicLink); - File.CreateSymbolicLink(symbolicLink, sourceLocation); - // Mock the creation date to fake the "date added" order in Jellyfin. - File.SetCreationTime(symbolicLink, importedAt); result.FixedVideos++; + if (!preview) { + File.Delete(symbolicLink); + File.CreateSymbolicLink(symbolicLink, sourceLocation); + // Mock the creation date to fake the "date added" order in Jellyfin. + File.SetCreationTime(symbolicLink, importedAt); + } } else { result.SkippedVideos++; @@ -878,8 +929,10 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ result.Paths.Add(subtitleLink); if (!File.Exists(subtitleLink)) { result.CreatedSubtitles++; - Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); - File.CreateSymbolicLink(subtitleLink, subtitleSource); + if (!preview) { + Logger.LogDebug("Linking {Link} → {LinkTarget}", subtitleLink, subtitleSource); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } } else { var shouldFix = false; @@ -887,18 +940,21 @@ public LinkGenerationResult GenerateSymbolicLinks(string sourceLocation, string[ var nextTarget = File.ResolveLinkTarget(subtitleLink, false); if (!string.Equals(subtitleSource, nextTarget?.FullName)) { shouldFix = true; - - Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", subtitleLink, subtitleSource, nextTarget?.FullName); + if (!preview) + Logger.LogWarning("Fixing broken symbolic link {Link} → {LinkTarget} (RealTarget={RealTarget})", subtitleLink, subtitleSource, nextTarget?.FullName); } } catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); shouldFix = true; + if (!preview) + Logger.LogError(ex, "Encountered an error trying to resolve symbolic link {Link} for {LinkTarget}", subtitleLink, subtitleSource); } if (shouldFix) { - File.Delete(subtitleLink); - File.CreateSymbolicLink(subtitleLink, subtitleSource); result.FixedSubtitles++; + if (!preview) { + File.Delete(subtitleLink); + File.CreateSymbolicLink(subtitleLink, subtitleSource); + } } else { result.SkippedSubtitles++; @@ -943,9 +999,10 @@ private List<string> FindSubtitlesForPath(string sourcePath) return externalPaths; } - private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths) + private LinkGenerationResult CleanupStructure(string vfsPath, string directoryToClean, IReadOnlyList<string> allKnownPaths, bool preview = false) { - Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); + if (!preview) + Logger.LogDebug("Looking for files to remove in folder at {Path}", directoryToClean); var start = DateTime.Now; var previousStep = start; var result = new LinkGenerationResult(); @@ -953,59 +1010,79 @@ private LinkGenerationResult CleanupStructure(string vfsPath, string directoryTo var toBeRemoved = FileSystem.GetFilePaths(directoryToClean, true) .Select(path => (path, extName: Path.GetExtension(path))) .Where(tuple => !string.IsNullOrEmpty(tuple.extName) && searchFiles.Contains(tuple.extName)) - .ExceptBy(allKnownPaths.ToHashSet(), tuple => tuple.path) + .ExceptBy(allKnownPaths, tuple => tuple.path) .ToList(); var nextStep = DateTime.Now; - Logger.LogDebug("Found {FileCount} files to remove in {DirectoryToClean} in {TimeSpent}", toBeRemoved.Count, directoryToClean, nextStep - previousStep); + if (!preview) + Logger.LogDebug("Found {FileCount} files to remove in {DirectoryToClean} in {TimeSpent}", toBeRemoved.Count, directoryToClean, nextStep - previousStep); previousStep = nextStep; foreach (var (location, extName) in toBeRemoved) { if (extName is ".nfo") { - try { - Logger.LogTrace("Removing NFO file at {Path}", location); - File.Delete(location); - } - catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); - continue; + if (!preview) { + try { + Logger.LogTrace("Removing NFO file at {Path}", location); + File.Delete(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); + continue; + } } + result.RemovedPaths.Add(location); result.RemovedNfos++; } else if (NamingOptions.SubtitleFileExtensions.Contains(extName)) { - if (TryMoveSubtitleFile(allKnownPaths, location)) { - result.FixedSubtitles++; + if (TryMoveSubtitleFile(allKnownPaths, location, preview)) { + result.Paths.Add(location); + if (preview) { + result.SkippedSubtitles++; + } + else { + result.FixedSubtitles++; + } continue; } - try { - Logger.LogTrace("Removing subtitle file at {Path}", location); - File.Delete(location); - } - catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); - continue; + if (!preview) { + try { + Logger.LogTrace("Removing subtitle file at {Path}", location); + File.Delete(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); + continue; + } } + result.RemovedPaths.Add(location); result.RemovedSubtitles++; } else { if (ShouldIgnoreVideo(vfsPath, location)) { + result.Paths.Add(location); result.SkippedVideos++; continue; } - try { - Logger.LogTrace("Removing video file at {Path}", location); - File.Delete(location); - } - catch (Exception ex) { - Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); - continue; + if (!preview) { + try { + Logger.LogTrace("Removing video file at {Path}", location); + File.Delete(location); + } + catch (Exception ex) { + Logger.LogError(ex, "Encountered an error trying to remove {FilePath}", location); + continue; + } } + result.RemovedPaths.Add(location); result.RemovedVideos++; } } + if (preview) + return result; + nextStep = DateTime.Now; Logger.LogTrace("Removed {FileCount} files in {DirectoryToClean} in {TimeSpent} (Total={TotalSpent})", result.Removed, directoryToClean, nextStep - previousStep, nextStep - start); previousStep = nextStep; @@ -1047,7 +1124,7 @@ private LinkGenerationResult CleanupStructure(string vfsPath, string directoryTo return result; } - private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, string subtitlePath) + private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, string subtitlePath, bool preview) { if (!TryGetIdsForPath(subtitlePath, out var seriesId, out var fileId)) return false; @@ -1069,6 +1146,9 @@ private static bool TryMoveSubtitleFile(IReadOnlyList<string> allKnownPaths, str if (string.IsNullOrEmpty(realTarget)) return false; + if (preview) + return true; + var realSubtitlePath = realTarget[..^Path.GetExtension(realTarget).Length] + extName; if (!File.Exists(realSubtitlePath)) File.Move(subtitlePath, realSubtitlePath); diff --git a/Shokofin/Web/Models/VfsLibraryPreview.cs b/Shokofin/Web/Models/VfsLibraryPreview.cs new file mode 100644 index 00000000..c0636430 --- /dev/null +++ b/Shokofin/Web/Models/VfsLibraryPreview.cs @@ -0,0 +1,67 @@ +using System.Collections.Generic; +using System.IO; +using System.Linq; +using MediaBrowser.Model.Entities; +using Shokofin.Resolvers.Models; + +namespace Shokofin.Web.Models; + +public class VfsLibraryPreview(HashSet<string> filesBefore, HashSet<string> filesAfter, VirtualFolderInfo virtualFolder, LinkGenerationResult? result, string vfsPath) +{ + public string LibraryId = virtualFolder.ItemId; + + public string LibraryName { get; } = virtualFolder.Name; + + public string CollectionType { get; } = virtualFolder.CollectionType.ConvertToCollectionType()?.ToString() ?? "-"; + + public string VfsRoot { get; } = Plugin.Instance.VirtualRoot; + + public bool IsSuccess = result is not null; + + public IReadOnlyList<string> FilesBeforeChanges { get; } = filesBefore + .Select(path => path.Replace(vfsPath, string.Empty).Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(path => path) + .ToList(); + + public IReadOnlyList<string> FilesAfterChanges { get; } = filesAfter + .Select(path => path.Replace(vfsPath, string.Empty).Replace(Path.DirectorySeparatorChar, '/')) + .OrderBy(path => path) + .ToList(); + + public VfsLibraryPreviewStats Stats { get; } = new(result); + + public class VfsLibraryPreviewStats(LinkGenerationResult? result) + { + public int Total { get; } = result?.Total ?? 0; + + public int Created { get; } = result?.Created ?? 0; + + public int Fixed { get; } = result?.Fixed ?? 0; + + public int Skipped { get; } = result?.Skipped ?? 0; + + public int Removed { get; } = result?.Removed ?? 0; + + public int TotalVideos { get; } = result?.TotalVideos ?? 0; + + public int CreatedVideos { get; } = result?.CreatedVideos ?? 0; + + public int FixedVideos { get; } = result?.FixedVideos ?? 0; + + public int SkippedVideos { get; } = result?.SkippedVideos ?? 0; + + public int RemovedVideos { get; } = result?.RemovedVideos ?? 0; + + public int TotalSubtitles { get; } = result?.TotalSubtitles ?? 0; + + public int CreatedSubtitles { get; } = result?.CreatedSubtitles ?? 0; + + public int FixedSubtitles { get; } = result?.FixedSubtitles ?? 0; + + public int SkippedSubtitles { get; } = result?.SkippedSubtitles ?? 0; + + public int RemovedSubtitles { get; } = result?.RemovedSubtitles ?? 0; + + public int RemovedNfos { get; } = result?.RemovedNfos ?? 0; + } +} \ No newline at end of file diff --git a/Shokofin/Web/UtilityApiController.cs b/Shokofin/Web/UtilityApiController.cs new file mode 100644 index 00000000..b1db91e6 --- /dev/null +++ b/Shokofin/Web/UtilityApiController.cs @@ -0,0 +1,49 @@ +using System; +using System.Net.Mime; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc; +using Microsoft.Extensions.Logging; +using Shokofin.Configuration; +using Shokofin.Resolvers; +using Shokofin.Web.Models; + +namespace Shokofin.Web; + +/// <summary> +/// Shoko Utility Web Controller. +/// </summary> +/// <remarks> +/// Initializes a new instance of the <see cref="UtilityApiController"/> class. +/// </remarks> +[ApiController] +[Route("Plugin/Shokofin/Utility")] +[Produces(MediaTypeNames.Application.Json)] +public class UtilityApiController(ILogger<UtilityApiController> logger, MediaFolderConfigurationService mediaFolderConfigurationService, VirtualFileSystemService virtualFileSystemService) : ControllerBase +{ + private readonly ILogger<UtilityApiController> Logger = logger; + + private readonly MediaFolderConfigurationService ConfigurationService = mediaFolderConfigurationService; + + private readonly VirtualFileSystemService VirtualFileSystemService = virtualFileSystemService; + + /// <summary> + /// Previews the VFS structure for the given library. + /// </summary> + /// <param name="libraryId">The id of the library to preview.</param> + /// <returns>A <see cref="VfsLibraryPreview"/> or <see cref="ValidationProblemDetails"/> if the library is not found.</returns> + [HttpPost("VFS/Library/{libraryId}/Preview")] + public async Task<ActionResult<VfsLibraryPreview>> PreviewVFS(Guid libraryId) + { + var trackerId = Plugin.Instance.Tracker.Add("Preview VFS"); + try { + var (filesBefore, filesAfter, virtualFolder, result, vfsPath) = await VirtualFileSystemService.PreviewChangesForLibrary(libraryId); + if (virtualFolder is null) + return NotFound("Unable to find library with the given id."); + + return new VfsLibraryPreview(filesBefore, filesAfter, virtualFolder, result, vfsPath); + } + finally { + Plugin.Instance.Tracker.Remove(trackerId); + } + } +} \ No newline at end of file From 3369217767b1544ad0a306804d8b8ade6022185d Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Mon, 7 Oct 2024 19:46:59 +0200 Subject: [PATCH 139/144] fix: start uglifying the year regex for season merging - If you know which show this is for, then good for you. Otherwise you don't need to pay it any mind. --- Shokofin/API/ShokoAPIManager.cs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/Shokofin/API/ShokoAPIManager.cs b/Shokofin/API/ShokoAPIManager.cs index 2d5fc86d..16be63c3 100644 --- a/Shokofin/API/ShokoAPIManager.cs +++ b/Shokofin/API/ShokoAPIManager.cs @@ -20,7 +20,8 @@ namespace Shokofin.API; public class ShokoAPIManager : IDisposable { - private static readonly Regex YearRegex = new(@"\s+\((?<year>\d{4})\)\s*$", RegexOptions.Compiled); + // Note: This regex will only get uglier with time. + private static readonly Regex YearRegex = new(@"\s+\((?<year>\d{4})(?:dai [2-9] bu)?\)\s*$", RegexOptions.Compiled | RegexOptions.IgnoreCase); private readonly ILogger<ShokoAPIManager> Logger; From 8ec4bccc06c1d963566bd79ad7b6887998bd3dec Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 9 Oct 2024 03:27:47 +0200 Subject: [PATCH 140/144] fix: remove VFS library roots with the same id but wrong parent folder - Remove any and all VFS library roots from the library with the correct library ID but the wrong parent folder(s). --- Shokofin/Configuration/MediaFolderConfigurationService.cs | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/Shokofin/Configuration/MediaFolderConfigurationService.cs b/Shokofin/Configuration/MediaFolderConfigurationService.cs index 7ee20a1f..1f8a41d6 100644 --- a/Shokofin/Configuration/MediaFolderConfigurationService.cs +++ b/Shokofin/Configuration/MediaFolderConfigurationService.cs @@ -330,6 +330,7 @@ private async Task GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualF continue; var vfsPath = libraryFolder.GetVirtualRoot(); + var vfsFolderName = Path.GetFileName(vfsPath); var shouldAttach = config.VFS_AttachRoot && mediaFolderConfig.IsVirtualFileSystemEnabled; if (shouldAttach && !virtualFolder.Locations.Contains(vfsPath, Path.DirectorySeparatorChar is '\\' ? StringComparer.OrdinalIgnoreCase : StringComparer.Ordinal)) { if (!LibraryEdits.TryGetValue(libraryId, out var edits)) @@ -339,7 +340,11 @@ private async Task GenerateAllConfigurations(List<VirtualFolderInfo> allVirtualF var toRemove = virtualFolder.Locations .Except(shouldAttach ? [vfsPath] : []) - .Where(location => Plugin.Instance.AllVirtualRoots.Any(virtualRoot => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) + .Where(location => + // In case the VFS root changes. + (string.Equals(Path.GetFileName(location), vfsFolderName) && !string.Equals(location, vfsPath)) || + // In case the libraryId changes but the root remains the same. + Plugin.Instance.AllVirtualRoots.Any(virtualRoot => location.StartsWith(virtualRoot, Path.DirectorySeparatorChar is '\\' ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal))) .ToList(); if (toRemove.Count > 0) { if (!LibraryEdits.TryGetValue(libraryId, out var edits)) From dd9a2ce9f259f6523ace4ba582866a62b3fd8554 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 9 Oct 2024 04:12:50 +0200 Subject: [PATCH 141/144] misc: fix doc links --- Shokofin/Pages/Scripts/Common.js | 18 +++++++++--------- Shokofin/Pages/Settings.html | 2 +- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/Shokofin/Pages/Scripts/Common.js b/Shokofin/Pages/Scripts/Common.js index aab4688f..1e316385 100644 --- a/Shokofin/Pages/Scripts/Common.js +++ b/Shokofin/Pages/Scripts/Common.js @@ -497,7 +497,7 @@ export const State = window["SHOKO_STATE_OBJECT"] || (window["SHOKO_STATE_OBJECT * @property {boolean?} expertMode Optional. Whether the tab is only rendered when in or not in expert mode. */ -const DefaultHelpLink = "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/"; +const DefaultHelpLink = "https://docs.shokoanime.com/jellyfin/configuring-shokofin/"; /** * @type {readonly ShokoTab[]} @@ -506,48 +506,48 @@ const Tabs = [ { id: "connection", href: getConfigurationPageUrl("Shoko.Settings", "connection"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#connecting-to-shoko-server", + helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#connecting-to-shoko-server", name: "Connection", }, { id: "metadata", href: getConfigurationPageUrl("Shoko.Settings", "metadata"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#metadata", + helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#metadata", name: "Metadata", connected: true, }, { id: "library", href: getConfigurationPageUrl("Shoko.Settings", "library"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#library", + helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#library", name: "Library", connected: true, }, { id: "vfs", href: getConfigurationPageUrl("Shoko.Settings", "vfs"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#vfs", + helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#vfs", name: "VFS", connected: true, }, { id: "users", href: getConfigurationPageUrl("Shoko.Settings", "users"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#users", + helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#users", name: "Users", connected: true, }, { id: "signalr", href: getConfigurationPageUrl("Shoko.Settings", "signalr"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#signalr", + helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#signalr", name: "SignalR", connected: true, }, { id: "misc", href: getConfigurationPageUrl("Shoko.Settings", "misc"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/#misc", + helpHref: "https://docs.shokoanime.com/jellyfin/configuring-shokofin/#misc", name: "Misc", connected: true, expertMode: true, @@ -555,7 +555,7 @@ const Tabs = [ { id: "utilities", href: getConfigurationPageUrl("Shoko.Settings", "utilities"), - helpHref: "https://docs.shokoanime.com/jellyfin-integration/utilities", + helpHref: "https://docs.shokoanime.com/jellyfin/utilities", name: "Utilities", }, ]; diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index 414e8192..f4bf713d 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -27,7 +27,7 @@ <div class="verticalSection verticalSection-extrabottompadding"> <div class="sectionTitleContainer flex align-items-center"> <h2 class="sectionTitle">Shoko</h2> - <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton emby-button" target="_blank" href="https://docs.shokoanime.com/jellyfin-integration/configuring-shokofin/">Help</a> + <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt headerHelpButton emby-button" target="_blank" href="https://docs.shokoanime.com/jellyfin/configuring-shokofin/">Help</a> </div> <fieldset id="Connection_Section" class="verticalSection verticalSection-extrabottompadding" hidden> <legend> From 123016f62f4ac5a386c91d45619a133b14686671 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 9 Oct 2024 05:46:50 +0200 Subject: [PATCH 142/144] fix: properly implement the respect preferred image _option_ - Hook up the code to actually use the option to respect the preferred image, instead of just always doing it. --- Shokofin/Providers/ImageProvider.cs | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/Shokofin/Providers/ImageProvider.cs b/Shokofin/Providers/ImageProvider.cs index 862ec2a8..f96cb954 100644 --- a/Shokofin/Providers/ImageProvider.cs +++ b/Shokofin/Providers/ImageProvider.cs @@ -46,6 +46,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell var list = new List<RemoteImageInfo>(); var metadataLanguage = item.GetPreferredMetadataLanguage(); var baseKind = item.GetBaseItemKind(); + var sortPreferred = Plugin.Instance.Configuration.RespectPreferredImage; var trackerId = Plugin.Instance.Tracker.Add($"Providing images for {baseKind} \"{item.Name}\". (Path=\"{item.Path}\")"); try { switch (item) { @@ -53,14 +54,13 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetEpisodeIdFor(episode, out var episodeId)) { var episodeImages = await ApiClient.GetEpisodeImages(episodeId); if (episodeImages is not null) - AddImagesForEpisode(ref list, episodeImages, metadataLanguage); + AddImagesForEpisode(ref list, episodeImages, metadataLanguage, sortPreferred); Logger.LogInformation("Getting {Count} images for episode {EpisodeName} (Episode={EpisodeId},Language={MetadataLanguage})", list.Count, episode.Name, episodeId, metadataLanguage); } break; } case Series series: { if (Lookup.TryGetSeriesIdFor(series, out var seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); - var sortPreferred = true; if (seriesImages is not null) { AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); sortPreferred = false; @@ -96,7 +96,6 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetSeriesIdFor(season, out var seriesId)) { var seasonInfo = await ApiManager.GetSeasonInfoForSeries(seriesId); var seriesImages = await ApiClient.GetSeriesImages(seriesId); - var sortPreferred = true; if (seriesImages is not null) { AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); sortPreferred = false; @@ -121,7 +120,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (Lookup.TryGetEpisodeIdFor(movie, out var episodeId)) { var episodeImages = await ApiClient.GetEpisodeImages(episodeId); if (episodeImages is not null) - AddImagesForSeries(ref list, episodeImages, metadataLanguage); + AddImagesForSeries(ref list, episodeImages, metadataLanguage, sortPreferred); Logger.LogInformation("Getting {Count} images for movie {MovieName} (Episode={EpisodeId},Language={MetadataLanguage})", list.Count, movie.Name, episodeId, metadataLanguage); } break; @@ -134,7 +133,7 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell if (!string.IsNullOrEmpty(seriesId)) { var seriesImages = await ApiClient.GetSeriesImages(seriesId); if (seriesImages is not null) - AddImagesForSeries(ref list, seriesImages, metadataLanguage); + AddImagesForSeries(ref list, seriesImages, metadataLanguage, sortPreferred); Logger.LogInformation("Getting {Count} images for collection {CollectionName} (Group={GroupId},Series={SeriesId},Language={MetadataLanguage})", list.Count, collection.Name, groupId, groupId == null ? seriesId : null, metadataLanguage); } break; @@ -151,13 +150,16 @@ public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, Cancell } } - public static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Models.EpisodeImages images, string metadataLanguage) + public static void AddImagesForEpisode(ref List<RemoteImageInfo> list, API.Models.EpisodeImages images, string metadataLanguage, bool sortList) { - foreach (var image in images.Thumbnails.OrderByDescending(image => image.IsPreferred)) + IEnumerable<API.Models.Image> imagesList = sortList + ? images.Thumbnails.OrderByDescending(image => image.IsPreferred) + : images.Thumbnails; + foreach (var image in imagesList) AddImage(ref list, ImageType.Primary, image, metadataLanguage); } - private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images, string metadataLanguage, bool sortList = true) + private static void AddImagesForSeries(ref List<RemoteImageInfo> list, API.Models.Images images, string metadataLanguage, bool sortList) { IEnumerable<API.Models.Image> imagesList = sortList ? images.Posters.OrderByDescending(image => image.IsPreferred) From f11bef4145bce03c108a656bc491aa69198e5924 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Wed, 9 Oct 2024 06:24:54 +0200 Subject: [PATCH 143/144] fix: check if enabled during user data scan before looking up ids --- Shokofin/Sync/UserDataSyncManager.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Shokofin/Sync/UserDataSyncManager.cs b/Shokofin/Sync/UserDataSyncManager.cs index d14e8c52..9ef7a1f6 100644 --- a/Shokofin/Sync/UserDataSyncManager.cs +++ b/Shokofin/Sync/UserDataSyncManager.cs @@ -392,7 +392,7 @@ public async Task ScanAndSync(SyncDirection direction, IProgress<double> progres foreach (var video in videos) { cancellationToken.ThrowIfCancellationRequested(); - if (!(Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) + if (!(Lookup.IsEnabledForItem(video) && Lookup.TryGetFileIdFor(video, out var fileId) && Lookup.TryGetEpisodeIdFor(video, out var episodeId))) continue; foreach (var userConfig in enabledUsers) { From e163f9968b451c9db5faad0a71568e57259d1132 Mon Sep 17 00:00:00 2001 From: Mikal Stordal <mikalstordal@gmail.com> Date: Thu, 10 Oct 2024 04:59:35 +0200 Subject: [PATCH 144/144] misc: final update of settings page - Comb through the whole settings page and update it to be more in line with the new documentation. It is not a 1:1 copy of the docs, but very close. [skip ci] --- Shokofin/Pages/Scripts/Common.js | 5 +- Shokofin/Pages/Scripts/Settings.js | 4 +- Shokofin/Pages/Settings.html | 396 ++++++++++++++--------------- 3 files changed, 198 insertions(+), 207 deletions(-) diff --git a/Shokofin/Pages/Scripts/Common.js b/Shokofin/Pages/Scripts/Common.js index 1e316385..28c10321 100644 --- a/Shokofin/Pages/Scripts/Common.js +++ b/Shokofin/Pages/Scripts/Common.js @@ -973,9 +973,8 @@ function onLinkRedirectClick(event) { * @returns {void} Void. */ function overrideLink(target) { - const href = target.getAttribute("href"); - target.dataset.href = href; - target.href = location.href.split("#")[0] + "#" + getConfigurationPageUrl(href); + const page = target.dataset.page; + target.href = location.href.split("#")[0] + "#" + getConfigurationPageUrl(page); target.addEventListener("click", onLinkRedirectClick); } diff --git a/Shokofin/Pages/Scripts/Settings.js b/Shokofin/Pages/Scripts/Settings.js index 75e7a498..aab2e7e8 100644 --- a/Shokofin/Pages/Scripts/Settings.js +++ b/Shokofin/Pages/Scripts/Settings.js @@ -410,7 +410,7 @@ function applyFormToConfig(form, config) { config.UseGroupsForShows = form.querySelector("#UseGroupsForShows").checked; config.SeasonOrdering = form.querySelector("#SeasonOrdering").value; config.SeparateMovies = form.querySelector("#SeparateMovies").checked; - config.FilterMovieLibraries = form.querySelector("#FilterMovieLibraries").checked; + config.FilterMovieLibraries = !form.querySelector("#DisableFilterMovieLibraries").checked; config.SpecialsPlacement = form.querySelector("#SpecialsPlacement").value; config.MovieSpecialsAsExtraFeaturettes = form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked; config.AddMissingMetadata = form.querySelector("#AddMissingMetadata").checked; @@ -576,7 +576,7 @@ async function applyConfigToForm(form, config) { } form.querySelector("#SeasonOrdering").value = config.SeasonOrdering; form.querySelector("#SeparateMovies").checked = config.SeparateMovies; - form.querySelector("#FilterMovieLibraries").checked = config.FilterMovieLibraries; + form.querySelector("#DisableFilterMovieLibraries").checked = !config.FilterMovieLibraries; form.querySelector("#SpecialsPlacement").value = config.SpecialsPlacement === "Default" ? "AfterSeason" : config.SpecialsPlacement; form.querySelector("#MovieSpecialsAsExtraFeaturettes").checked = config.MovieSpecialsAsExtraFeaturettes; form.querySelector("#AddMissingMetadata").checked = config.AddMissingMetadata; diff --git a/Shokofin/Pages/Settings.html b/Shokofin/Pages/Settings.html index f4bf713d..dd328585 100644 --- a/Shokofin/Pages/Settings.html +++ b/Shokofin/Pages/Settings.html @@ -34,31 +34,31 @@ <h2 class="sectionTitle">Shoko</h2> <h3>Connection Settings</h3> </legend> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="Url" label="Private Host Url" /> - <div class="fieldDescription">This is the private URL leading to where Shoko is running. It will be used internally in Jellyfin and also for all images sent to clients and redirects back to the Shoko instance if you don't set a public host URL below. It <i>should</i> include both the protocol and the IP/DNS name.</div> + <input is="emby-input" type="text" id="Url" label="Private Host URL" /> + <div class="fieldDescription">This is the private URL leading to where Shoko is running. It will be used internally in Jellyfin in addition to all images sent to clients and redirects back to Shoko if you don't set a public host URL below. It <i>should</i> include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> - <input is="emby-input" type="text" id="PublicUrl" label="Public Host Url" /> + <input is="emby-input" type="text" id="PublicUrl" label="Public Host URL" /> <div class="fieldDescription">Optional. This is the public URL leading to where Shoko is running. It can be used to redirect to Shoko if you click on a Shoko ID in the UI if Shoko and/or Jellyfin is running within a container and you cannot access Shoko from the host URL provided in the connection settings section above. It will also be used for images from the plugin when viewing the "Edit Images" modal in clients. It <i>should</i> include both the protocol and the IP/DNS name.</div> </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="Username" label="Username" /> - <div class="fieldDescription">The user should be an administrator in Shoko, preferably without any filtering applied.</div> + <div class="fieldDescription">The username of your administrator account in Shoko.</div> </div> <div id="ConnectionSetContainer"> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="password" id="Password" label="Password" /> - <div class="fieldDescription">The password for account. It can be empty.</div> + <div class="fieldDescription">The password of your administrator account in Shoko.</div> </div> <button is="emby-button" type="submit" name="establish-connection" class="raised button-submit block emby-button"> <span>Connect</span> </button> - <div class="fieldDescription">Establish a connection to Shoko Server using the provided credentials.</div> + <div class="fieldDescription">Establish a connection to Shoko using the provided credentials.</div> </div> <div id="ConnectionResetContainer" hidden> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="ServerVersion" label="Server Version" readonly value="Unknown Version" /> - <div class="fieldDescription">The version of Shoko Server we're connected to.</div> + <div class="fieldDescription">The version of Shoko we're connected to.</div> </div> <button is="emby-button" type="submit" name="reset-connection" class="raised block emby-button"> <span>Reset Connection</span> @@ -71,7 +71,7 @@ <h3>Connection Settings</h3> <h3>Title Settings</h3> </legend> <div id="TitleMainList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Main title source</h3> + <h3 class="checkboxListLabel">Main Title Source</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> @@ -158,10 +158,10 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </button> </div> </div> - <div class="fieldDescription">The metadata providers to use as the source of the main title, in priority order.</div> + <div class="fieldDescription">The metadata providers to use as the source of the main title for entities, in priority order.</div> </div> <div id="TitleAlternateList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Alternate/original title source</h3> + <h3 class="checkboxListLabel">Alternate/Original Title Source</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption"> <label class="listItemCheckboxContainer"> @@ -248,21 +248,21 @@ <h3 class="listItemBodyText">TMDB | Use the language from the media's country of </button> </div> </div> - <div class="fieldDescription">The metadata providers to use as the source of the alternate/original title, in priority order.</div> + <div class="fieldDescription">The metadata providers to use as the source of the alternate/original title for entities, in priority order.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="MarkSpecialsWhenGrouped" /> - <span>Add prefix to episode titles</span> + <span>Add Prefix to Episodes</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add the type and number to the title of some episodes.</div> + <div class="fieldDescription checkboxFieldDescription">Adds the type and number to the title of non-standard episodes such as specials. (e.g. S1)</div> </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="TitleAllowAny" /> - <span>Allow any title in selected language</span> + <span>Allow Any Title in Selected Language</span> </label> - <div class="fieldDescription checkboxFieldDescription">Will add any title in the selected language if no official title is found.</div> + <div class="fieldDescription checkboxFieldDescription">Allows for any titles to be utilized if an official title is not present in the given language. Only applies to the AniDB title selectors above.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -275,12 +275,12 @@ <h3>Description Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="CleanupAniDBDescriptions" /> - <span>Cleanup AniDB overviews</span> + <span>Cleanup AniDB Descriptions</span> </label> - <div class="fieldDescription checkboxFieldDescription">Remove links and collapse multiple empty lines into one empty line, and trim any lines starting with '* ', '-- ', '~ ', 'Note', 'Source', and/or 'Summary'.</div> + <div class="fieldDescription checkboxFieldDescription">Prettifies AniDB descriptions and convert them to markdown.</div> </div> <div id="DescriptionSourceList" is="sortable-checkbox-list" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Description source</h3> + <h3 class="checkboxListLabel">Description Source</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="Shoko"> <label class="listItemCheckboxContainer"> @@ -333,7 +333,7 @@ <h3 class="listItemBodyText">TvDB | Follow metadata language in library</h3> </button> </div> </div> - <div class="fieldDescription">The metadata providers to use as the source of descriptions, in priority order.</div> + <div class="fieldDescription">The metadata providers to use as the source of descriptions for entities, in priority order.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -346,12 +346,12 @@ <h3>Tag & Genre Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="HideUnverifiedTags" /> - <span>Ignore unverified tags.</span> + <span>Ignore Unverified Tags</span> </label> - <div class="fieldDescription checkboxFieldDescription">Will ignore all tags not yet verified on AniDB, so they won't show up as tags/genres for entries.</div> + <div class="fieldDescription checkboxFieldDescription">Don't use any user-submitted tags that have not been verified by AniDB.</div> </div> <div id="TagSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Tag sources</h3> + <h3 class="checkboxListLabel">Tag Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -359,7 +359,7 @@ <h3 class="checkboxListLabel">Tag sources</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Content Indicators</h3> + <h3 class="listItemBodyText">Content indicators</h3> </div> </div> <div class="listItem"> @@ -404,7 +404,7 @@ <h3 class="listItemBodyText">Elements | General</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> + <h3 class="listItemBodyText">Elements | Pornography & sexual abuse</h3> </div> </div> <div class="listItem"> @@ -413,7 +413,7 @@ <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Elements | Tropes & Motifs</h3> + <h3 class="listItemBodyText">Elements | Tropes & motifs</h3> </div> </div> <div class="listItem"> @@ -458,7 +458,7 @@ <h3 class="listItemBodyText">Setting | Place</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Setting | Time Period</h3> + <h3 class="listItemBodyText">Setting | Time period</h3> </div> </div> <div class="listItem"> @@ -467,7 +467,7 @@ <h3 class="listItemBodyText">Setting | Time Period</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> + <h3 class="listItemBodyText">Setting | Yearly seasons</h3> </div> </div> <div class="listItem"> @@ -476,7 +476,7 @@ <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Source Material</h3> + <h3 class="listItemBodyText">Source material</h3> </div> </div> <div class="listItem"> @@ -485,7 +485,7 @@ <h3 class="listItemBodyText">Source Material</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Target Audience</h3> + <h3 class="listItemBodyText">Target audience</h3> </div> </div> <div class="listItem"> @@ -494,7 +494,7 @@ <h3 class="listItemBodyText">Target Audience</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | General</h3> + <h3 class="listItemBodyText">Technical aspects | General</h3> </div> </div> <div class="listItem"> @@ -503,7 +503,7 @@ <h3 class="listItemBodyText">Technical Aspects | General</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> + <h3 class="listItemBodyText">Technical aspects | Adaptions</h3> </div> </div> <div class="listItem"> @@ -512,7 +512,7 @@ <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | Awards</h3> + <h3 class="listItemBodyText">Technical aspects | Awards</h3> </div> </div> <div class="listItem"> @@ -521,7 +521,7 @@ <h3 class="listItemBodyText">Technical Aspects | Awards</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | Multi-Anime Projects</h3> + <h3 class="listItemBodyText">Technical aspects | Multi-anime projects</h3> </div> </div> <div class="listItem"> @@ -575,14 +575,14 @@ <h3 class="listItemBodyText">Unsorted</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Custom User Tags</h3> + <h3 class="listItemBodyText">Custom user tags</h3> </div> </div> </div> - <div class="fieldDescription">The tag sources to use as the source of tags.</div> + <div class="fieldDescription">The tag categories to use as the sources of the displayed tags for entities.</div> </div> <div id="TagIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Tag include filters</h3> + <h3 class="checkboxListLabel">Tag Inclusion Filters</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -590,7 +590,7 @@ <h3 class="checkboxListLabel">Tag include filters</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Parent Tags</h3> + <h3 class="listItemBodyText">Type | Parent tags</h3> </div> </div> <div class="listItem"> @@ -599,7 +599,7 @@ <h3 class="listItemBodyText">Type | Parent Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Child Tags</h3> + <h3 class="listItemBodyText">Type | Child tags</h3> </div> </div> <div class="listItem"> @@ -608,7 +608,7 @@ <h3 class="listItemBodyText">Type | Child Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Abstract Tags</h3> + <h3 class="listItemBodyText">Type | Abstract tags</h3> </div> </div> <div class="listItem"> @@ -617,7 +617,7 @@ <h3 class="listItemBodyText">Type | Abstract Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Weightless Tags</h3> + <h3 class="listItemBodyText">Type | Weightless tags</h3> </div> </div> <div class="listItem"> @@ -626,7 +626,7 @@ <h3 class="listItemBodyText">Type | Weightless Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Weighted Tags</h3> + <h3 class="listItemBodyText">Type | Weighted tags</h3> </div> </div> <div class="listItem"> @@ -635,7 +635,7 @@ <h3 class="listItemBodyText">Type | Weighted Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> + <h3 class="listItemBodyText">Spoiler | Global spoiler</h3> </div> </div> <div class="listItem"> @@ -644,33 +644,33 @@ <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> + <h3 class="listItemBodyText">Spoiler | Local spoiler</h3> </div> </div> </div> - <div class="fieldDescription">The type of tags to include for tags.</div> + <div class="fieldDescription">The inclusion filters to use for the displayed tags for entities.</div> </div> <div class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="TagMinimumWeight">Minimum tag weight for tags</label> + <label class="selectLabel" for="TagMinimumWeight">Minimum Weight for Tags</label> <select is="emby-select" id="TagMinimumWeight" name="TagMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless" selected>All Allowed (Default)</option> - <option value="One">⯪☆☆</option> - <option value="Two">★☆☆</option> - <option value="Three">★⯪☆</option> - <option value="Four">★★☆</option> - <option value="Five">★★⯪</option> - <option value="Six">★★★</option> + <option value="One">0.5</option> + <option value="Two">1.0</option> + <option value="Three">1.5</option> + <option value="Four">2.0</option> + <option value="Five">2.5</option> + <option value="Six">3.0</option> </select> <div class="fieldDescription"> - The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. + Choose the minimum weight a tag must have to be included with the displayed tags, not including weightless tags. </div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" id="TagMaximumDepth" label="Maximum depth to add per tag source" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> - <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for tags.</div> + <input is="emby-input" id="TagMaximumDepth" label="Maximum Depth for Tags" placeholder="0" type="number" pattern="[0-9]*" min="0" max="10" step="1"> + <div class="fieldDescription">The maximum relative depth of a tag to be included with the displayed tags based on it's source category.</div> </div> <div id="GenreSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Genre sources</h3> + <h3 class="checkboxListLabel">Genre Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -678,7 +678,7 @@ <h3 class="checkboxListLabel">Genre sources</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Content Indicators</h3> + <h3 class="listItemBodyText">Content indicators</h3> </div> </div> <div class="listItem"> @@ -723,7 +723,7 @@ <h3 class="listItemBodyText">Elements | General</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> + <h3 class="listItemBodyText">Elements | Pornography & sexual abuse</h3> </div> </div> <div class="listItem"> @@ -732,7 +732,7 @@ <h3 class="listItemBodyText">Elements | Pornography & Sexual Abuse</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Elements | Tropes & Motifs</h3> + <h3 class="listItemBodyText">Elements | Tropes & motifs</h3> </div> </div> <div class="listItem"> @@ -777,7 +777,7 @@ <h3 class="listItemBodyText">Setting | Place</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Setting | Time Period</h3> + <h3 class="listItemBodyText">Setting | Time period</h3> </div> </div> <div class="listItem"> @@ -786,7 +786,7 @@ <h3 class="listItemBodyText">Setting | Time Period</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> + <h3 class="listItemBodyText">Setting | Yearly seasons</h3> </div> </div> <div class="listItem"> @@ -795,7 +795,7 @@ <h3 class="listItemBodyText">Setting | Yearly Seasons</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Source Material</h3> + <h3 class="listItemBodyText">Source material</h3> </div> </div> <div class="listItem"> @@ -804,7 +804,7 @@ <h3 class="listItemBodyText">Source Material</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Target Audience</h3> + <h3 class="listItemBodyText">Target audience</h3> </div> </div> <div class="listItem"> @@ -813,7 +813,7 @@ <h3 class="listItemBodyText">Target Audience</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | General</h3> + <h3 class="listItemBodyText">Technical aspects | General</h3> </div> </div> <div class="listItem"> @@ -822,7 +822,7 @@ <h3 class="listItemBodyText">Technical Aspects | General</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> + <h3 class="listItemBodyText">Technical aspects | Adaptions</h3> </div> </div> <div class="listItem"> @@ -831,7 +831,7 @@ <h3 class="listItemBodyText">Technical Aspects | Adaptions</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | Awards</h3> + <h3 class="listItemBodyText">Technical aspects | Awards</h3> </div> </div> <div class="listItem"> @@ -840,7 +840,7 @@ <h3 class="listItemBodyText">Technical Aspects | Awards</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Technical Aspects | Multi-Anime Projects</h3> + <h3 class="listItemBodyText">Technical aspects | Multi-anime projects</h3> </div> </div> <div class="listItem"> @@ -894,14 +894,14 @@ <h3 class="listItemBodyText">Unsorted</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Custom User Tags</h3> + <h3 class="listItemBodyText">Custom user tags</h3> </div> </div> </div> - <div class="fieldDescription">The tag sources to use as the source of genres.</div> + <div class="fieldDescription">The tag categories to use as the sources of the displayed genres for entities.</div> </div> <div id="GenreIncludeFilters" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Genre include filters</h3> + <h3 class="checkboxListLabel">Genre Inclusion Filters</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem"> <label class="listItemCheckboxContainer"> @@ -909,7 +909,7 @@ <h3 class="checkboxListLabel">Genre include filters</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Parent Tags</h3> + <h3 class="listItemBodyText">Type | Parent tags</h3> </div> </div> <div class="listItem"> @@ -918,7 +918,7 @@ <h3 class="listItemBodyText">Type | Parent Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Child Tags</h3> + <h3 class="listItemBodyText">Type | Child tags</h3> </div> </div> <div class="listItem"> @@ -927,7 +927,7 @@ <h3 class="listItemBodyText">Type | Child Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Abstract Tags</h3> + <h3 class="listItemBodyText">Type | Abstract tags</h3> </div> </div> <div class="listItem"> @@ -936,7 +936,7 @@ <h3 class="listItemBodyText">Type | Abstract Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Weightless Tags</h3> + <h3 class="listItemBodyText">Type | Weightless tags</h3> </div> </div> <div class="listItem"> @@ -945,7 +945,7 @@ <h3 class="listItemBodyText">Type | Weightless Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Type | Weighted Tags</h3> + <h3 class="listItemBodyText">Type | Weighted tags</h3> </div> </div> <div class="listItem"> @@ -954,7 +954,7 @@ <h3 class="listItemBodyText">Type | Weighted Tags</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> + <h3 class="listItemBodyText">Spoiler | Global spoiler</h3> </div> </div> <div class="listItem"> @@ -963,30 +963,30 @@ <h3 class="listItemBodyText">Spoiler | Global Spoiler</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">Spoiler | Local Spoiler</h3> + <h3 class="listItemBodyText">Spoiler | Local spoiler</h3> </div> </div> </div> - <div class="fieldDescription">The type of tags to include for genres.</div> + <div class="fieldDescription">The inclusion filters to use for the displayed genres for entities.</div> </div> <div class="selectContainer selectContainer-withDescription expert-only"> - <label class="selectLabel" for="GenreMinimumWeight">Minimum tag weight for genres</label> + <label class="selectLabel" for="GenreMinimumWeight">Minimum Weight for Genres</label> <select is="emby-select" id="GenreMinimumWeight" name="GenreMinimumWeight" class="emby-select-withcolor emby-select"> <option value="Weightless">All Allowed</option> - <option value="One">⯪☆☆</option> - <option value="Two">★☆☆</option> - <option value="Three" selected>★⯪☆</option> - <option value="Four">★★☆ (Default)</option> - <option value="Five">★★⯪</option> - <option value="Six">★★★</option> + <option value="One">0.5</option> + <option value="Two">1.0</option> + <option value="Three">1.5</option> + <option value="Four" selected>2.0 (Default)</option> + <option value="Five">2.5</option> + <option value="Six">3.0</option> </select> <div class="fieldDescription"> - The minimum weight of tags to be included, except weightless tags, which has their own filtering through the filtering above. + Choose the minimum weight a tag must have to be included with the displayed genres, not including weightless tags. </div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" id="GenreMaximumDepth" label="Maximum depth to add per genre source" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> - <div class="fieldDescription">The maximum relative depth of the tag to be included from it's source, for genres.</div> + <input is="emby-input" id="GenreMaximumDepth" label="Maximum Depth for Genres" placeholder="1" type="number" pattern="[0-9]*" min="0" max="10" step="1"> + <div class="fieldDescription">The maximum relative depth of a tag to be included with the displayed genres based on it's source category.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -999,16 +999,16 @@ <h3>Image Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddImageLanguageCode" /> - <span>Add language code</span> + <span>Add Language Code</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add language code to image metadata provided to Jellyfin for it to select the correct image to use for the library.</div> + <div class="fieldDescription checkboxFieldDescription">Add the language code to image metadata provided to Jellyfin, which it can use to prioritize images based on a library's configured metadata language. If a library has no language set, Jellyfin will prioritize English labeled images if this option is set, and will otherwise use the first available image.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="RespectPreferredImage" /> - <span>Respect preferred image</span> + <span>Respect Preferred Image</span> </label> - <div class="fieldDescription checkboxFieldDescription">Respect the preferred image flag sent from server when selecting the correct image to use for the library. Setting this will also set the language code to the preferred language code for the library if "Add language code" is enabled, thus ensuring it is always selected for the library.</div> + <div class="fieldDescription checkboxFieldDescription">Respect the preferred image flag sent from server when selecting the correct image to use for the library. Setting this will also set the language code to the preferred language code for the library if "Add Language Code" is enabled, thus ensuring it is always selected for the library.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1019,37 +1019,37 @@ <h3>Image Settings</h3> <h3>Miscellaneous Settings</h3> </legend> <div id="ContentRatingList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Content rating sources</h3> + <h3 class="checkboxListLabel">Content Rating Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> - <div class="listItem sortableOption" data-option="AniDB"> + <div class="listItem sortableOption" data-option="TMDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="AniDB"> + <input is="emby-checkbox" type="checkbox" data-option="TMDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h3> + <h3 class="listItemBodyText">TMDB | Follow country/region set in library</h3> </div> - <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> - <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> + <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> + <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> </button> </div> - <div class="listItem sortableOption" data-option="TMDB"> + <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> - <input is="emby-checkbox" type="checkbox" data-option="TMDB"> + <input is="emby-checkbox" type="checkbox" data-option="AniDB"> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TMDB | Follow country/region set in library</h3> + <h3 class="listItemBodyText">AniDB | Approximate content rating based on tags</h3> </div> - <button type="button" is="paper-icon-button-light" title="Down" class="btnSortableMoveDown btnSortable"> - <span class="material-icons keyboard_arrow_down" aria-hidden="true"></span> + <button type="button" is="paper-icon-button-light" title="Up" class="btnSortableMoveUp btnSortable"> + <span class="material-icons keyboard_arrow_up" aria-hidden="true"></span> </button> </div> </div> - <div class="fieldDescription">The metadata providers to use as the source of content ratings, in priority order.</div> + <div class="fieldDescription">The metadata providers to use as the source of content ratings for entities, in priority order.</div> </div> <div id="ProductionLocationList" is="sortable-checkbox-list" class="expert-only" style="margin-bottom: 2em;"> - <h3 class="checkboxListLabel">Production location sources</h3> + <h3 class="checkboxListLabel">Production Location Sources</h3> <div class="checkboxList paperList checkboxList-paperList"> <div class="listItem sortableOption" data-option="AniDB"> <label class="listItemCheckboxContainer"> @@ -1076,7 +1076,7 @@ <h3 class="listItemBodyText">TMDB</h3> </button> </div> </div> - <div class="fieldDescription">The metadata providers to use as the source of production locations, in priority order.</div> + <div class="fieldDescription">The metadata providers to use as the source of production locations for entities, in priority order.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1095,7 +1095,7 @@ <h3 class="checkboxListLabel">Add Third Party IDs</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">AniDB | Shows, Seasons, Episodes</h3> + <h3 class="listItemBodyText">AniDB | Series, Seasons, Episodes</h3> </div> </div> <div class="listItem"> @@ -1113,7 +1113,7 @@ <h3 class="listItemBodyText">TMDB | Shows, Seasons, Episodes, Movies</h3> <span></span> </label> <div class="listItemBody"> - <h3 class="listItemBodyText">TvDB | Show, Episode</h3> + <h3 class="listItemBodyText">TvDB | Shows, Episodes</h3> </div> </div> </div> @@ -1127,66 +1127,44 @@ <h3 class="listItemBodyText">TvDB | Show, Episode</h3> <legend> <h3>Basic Settings</h3> </legend> - <div class="checkboxContainer checkboxContainer-withDescription"> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AutoMergeVersions" /> - <span>Automatically merge multiple versions of videos</span> + <span>Automatically Merge Multiple Versions</span> </label> - <div class="fieldDescription checkboxFieldDescription"><div>Automatically merge multiple versions of the same video together after a library scan or refresh. Only applies to videos with Shoko IDs set.</div></div> + <div class="fieldDescription checkboxFieldDescription">Enable to automatically merge multiple versions of the same media info a single displayed entry after a library scan or refresh. Only applies to videos with Shoko IDs set.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="UseGroupsForShows" /> - <span>Use shoko groups for shows</span> + <span>Use Groups for Shows</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>This will use Shoko's group feature to group together AniDB anime entries into show entries.</div> + <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> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">Pre-requirements for use.</summary> - To make the most out of this feature you first need to configure your grouping in Shoko Server. You can - either enable auto-grouping in the settings, or manually craft your own grouping structure, or a - combination of the two where new series gets automatically assigned to a fitting group and you can - override the placement if you feel it should belong elsewhere instead. For more information look up the - <a href="https://docs.shokoanime.com/server/management">Shoko docs</a> on how to manage your groups. + To make the most out of this feature you first need to configure your groups in Shoko. You can either enable auto-grouping in the settings, manually craft your own group structure, or a mix of the two where you have auto-grouping enabled but override the placement if you feel it should belong elsewhere instead. For more information look up the <a href="https://docs.shokoanime.com/shoko-server/group-management">Shoko docs</a> on how to manage your groups. </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">Can I use this with collections?</summary> - Yes! You can use this with collections enabled, but that entails that you've configured a multi-layered - structure for your groups. Because the first group layer will be used for the shows, except if 1) the group - contains both movies and shows, and 2) you've separated the movies from the shows. In that case the - then the first layer of groups will also be used to generate a collection for your movie(s) and show - within the first layer. Also, the auto-grouping only acts on a single layer, and you need to use Shoko - Desktop (or in the future, the Web UI) to create your nested structure. For more information look up the - <a href="https://docs.shokoanime.com/server/management">Shoko docs</a> on how to manage your groups. + Yes! You can use this with collections enabled, but that entails most of the time that you've configured a multi-layered structure for your groups because the deepest layer will be used for the shows while all the layers above will be used for collections. The exception to this is if you have a group which contains both movies and shows and you've enabled the option to separate movies from shows. In which case the deepest layer of groups will also be used to generate a collection for your movie(s) and show within the layer. Also note that enabling auto-grouping only gives you a single layer, and you need to use Shoko Desktop (or in the future, the Web UI) to create your nested structure. For more information look up the <a href="https://docs.shokoanime.com/shoko-server/group-management">Shoko docs</a> on how to manage your groups. </details> </div> </div> <div id="SeasonOrderingContainer" class="selectContainer selectContainer-withDescription expert-only" hidden> - <label class="selectLabel" for="SeasonOrdering">Season ordering</label> + <label class="selectLabel" for="SeasonOrdering">Season Ordering</label> <select is="emby-select" id="SeasonOrdering" name="SeasonOrdering" class="emby-select-withcolor emby-select" disabled> - <option value="Default" selected>Let Shoko decide</option> + <option value="Default" selected>Let Shoko decide (Default)</option> <option value="ReleaseDate">Order seasons by release date</option> <option value="Chronological" class="expert-only">Order seasons in chronological order (use indirect relations) (EXPERIMENTAL)</option> <option value="ChronologicalIgnoreIndirect">Order seasons in chronological order (ignore indirect relations) (EXPERIMENTAL)</option> </select> - <div class="fieldDescription">Determines how to order seasons within each show using the Shoko groups.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="SeparateMovies" /> - <span>Separate movies from shows</span> - </label> - <div class="fieldDescription checkboxFieldDescription">This filters out movies from the shows in your library. Disable this if you want your movies to show up as episodes within seasons of your shows instead.</div> - </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> - <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="FilterMovieLibraries" /> - <span>Filter movie libraries</span> - </label> - <div class="fieldDescription checkboxFieldDescription">This filters out anything that is not a movie in any movie libraries. Disable this if you want your anything to show up as movies in your movie libraries instead.</div> + <div class="fieldDescription">Determines how to order seasons within shows when using groups for shows.</div> </div> <div class="selectContainer selectContainer-withDescription"> - <label class="selectLabel" for="SpecialsPlacement">Specials placement within seasons</label> + <label class="selectLabel" for="SpecialsPlacement">Specials Placement Within Seasons</label> <select is="emby-select" id="SpecialsPlacement" name="SpecialsPlacement" class="emby-select-withcolor emby-select"> <option value="AfterSeason">Always place specials after the normal episodes (Default)</option> <option value="InBetweenSeasonByAirDate">Use release dates to place specials</option> @@ -1196,19 +1174,33 @@ <h3>Basic Settings</h3> </select> <div class="fieldDescription selectFieldDescription">Determines how specials are placed within seasons. <strong>Warning:</strong> Modifying this setting requires a recreation (read as; delete existing then create a new) of any libraries using this plugin — otherwise you <strong>will</strong> have mixed metadata.</div> </div> - <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> - <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> - <span>Force movie special features</span> + <input is="emby-checkbox" type="checkbox" id="SeparateMovies" /> + <span>Separate Movies from Shows</span> </label> - <div class="fieldDescription checkboxFieldDescription">Append all specials in AniDB movie series as special features for the movies. By default only some specials will be automatically recognized as special features, but by enabling this option you will force all specials to be used as special features. This setting applies to movie series across all library types, and may break some movie series in a show type library unless appropriate measures are taken.</div> + <div class="fieldDescription checkboxFieldDescription">By default we allow movies to show up as episodes within seasons of your shows in show libraries. Enable this if you want your movies to be filtered out of your show libraries. Also note that enabling this option allows you to create collections for your shows and movies when using 'Use Groups for Shows' and have a flat group structure. <strong>This setting also applies to mixed type libraries.</strong></div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddMissingMetadata" /> <span>Add Missing Episodes/Seasons</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add the metadata for missing episodes/seasons not in the local collection.</div> + <div class="fieldDescription checkboxFieldDescription">Add the metadata for missing episodes/seasons not currently present in your local collection. Display settings for each Jellyfin user will have to be adjusted to show missing episode information to make use of this feature.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="DisableFilterMovieLibraries" /> + <span>Disable Movie Library Filtering</span> + </label> + <div class="fieldDescription checkboxFieldDescription">By default we filter out anything that is not a movie in movie libraries. Enable this if you want everything to show up as movies in your movie libraries instead.</div> + </div> + <div class="checkboxContainer checkboxContainer-withDescription expert-only"> + <label class="emby-checkbox-label"> + <input is="emby-checkbox" type="checkbox" id="MovieSpecialsAsExtraFeaturettes" /> + <span>Force Movie Special Features</span> + </label> + <div class="fieldDescription checkboxFieldDescription">Append all specials in an AniDB movie series as special features for the movie(s). By default only some specials will be automatically recognized as special features, but by enabling this option you will force all specials to be used as special features. This setting applies to movie series across all library types, and will break some movie series in a show type library.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1226,7 +1218,7 @@ <h3>Collection Settings</h3> <option value="Shared">Create collections for movies and shows based upon Shoko's groups and series</option> </select> <div class="fieldDescription"> - <div>Determines what entities to group into collections.</div> + <div>Determines what entities to group into native Jellyfin collections.</div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">Custom CSS for the collections</summary> Here's some optional custom CSS you can add to "rename" the sections in the collections to @@ -1254,9 +1246,9 @@ <h3>Collection Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="CollectionMinSizeOfTwo" /> - <span>Require two entries for a collection</span> + <span>Require Two Entries for a Collection</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add a minimum requirement of two entries with the same collection id before creating a collection for them.</div> + <div class="fieldDescription checkboxFieldDescription">If collections are enabled, only create a collection when the collection will contain at least two items.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1272,12 +1264,12 @@ <h3>New Library Settings</h3> <span>Use the Virtual File System (<strong>VFS</strong>)</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables the use of the VFS for any new media libraries managed by the plugin.</div> + <div>Enables the use of the VFS for any new libraries managed by the plugin.</div> <div id="WindowsSymLinkWarning1" hidden><strong>Warning</strong>: Windows users are required to <a href="https://learn.microsoft.com/en-us/windows/apps/get-started/developer-mode-features-and-debugging#use-regedit-to-enable-your-device" target="_blank" rel="noopener noreferrer">enable Developer Mode</a> then restart Jellyfin to be able to create symbolic links, a feature <strong>required</strong> to use the VFS.</div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does this mean?</summary> - Enabling this setting should in theory make it so you won't have to think about file structure incompatibilities, since it will override your existing file structure and replace it with a layer of symbolic links managed by the plugin instead of your actual file structure. -   + Enabling this feature allows you to disregard the underlying disk file structure while automagically meeting Jellyfin's requirements for file organization. It also ensures that no unrecognized files appear in your library and allows us to fully leverage Jellyfin's native features better than we otherwise could without it. This enables us to effortlessly support trailers, special features, and theme videos for series, seasons and movies, as well as merge partial episodes into a single episode. All this is possible because we disregard the underlying disk file structure to create our own using symbolic links. + <br/> <strong>Do not enable this for an existing library. If you do, then delete and re-create your library from scratch.</strong> </details> </div> @@ -1290,14 +1282,14 @@ <h3>New Library Settings</h3> <option value="Lax" selected>Lax</option> </select> <div class="fieldDescription"> - <div>Choose how the plugin filters out videos in your new libraries. This option only applies if the VFS is not used.</div> + <div>Adjust how the plugin filters out unrecognized media in your new libraries. This option only applies if the VFS is not used.</div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does auto filtering entail?</summary> - Auto filtering means the plugin will only filter out unrecognized videos if no other metadata provider is enabled, otherwise it will leave them in the library. + Auto filtering means the plugin will only filter out unrecognized media if no other metadata provider is enabled, otherwise it will leave them in the library. </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does strict filtering entail?</summary> - Strict filtering means the plugin will filter out any and all unrecognized videos from the library. + Strict filtering means the plugin will filter out any and all unrecognized media from the library. </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does lax filtering entail?</summary> @@ -1351,14 +1343,14 @@ <h3 class="checkboxListLabel">Import Folder Mapping</h3> <option value="Lax" selected>Lax</option> </select> <div class="fieldDescription"> - <div>Choose how the plugin filters out videos in your new libraries. This option only applies if the VFS is not used.</div> + <div>Adjust how the plugin filters out unrecognized media in the library. This option only applies if the VFS is not used.</div> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does auto filtering entail?</summary> - Auto filtering means the plugin will only filter out unrecognized videos if no other metadata provider is enabled, otherwise it will leave them in the library. + Auto filtering means the plugin will only filter out unrecognized media if no other metadata provider is enabled, otherwise it will leave them in the library. </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does strict filtering entail?</summary> - Strict filtering means the plugin will filter out any and all unrecognized videos from the library. + Strict filtering means the plugin will filter out any and all unrecognized media from the library. </details> <details style="margin-top: 0.5em"> <summary style="margin-bottom: 0.25em">What does lax filtering entail?</summary> @@ -1385,9 +1377,9 @@ <h3>Experimental Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="EXPERIMENTAL_MergeSeasons" /> - <span>Automatically merge seasons</span> + <span>Automatically Merge Seasons</span> </label> - <div class="fieldDescription checkboxFieldDescription"><div>Blur the boundaries between AniDB anime further by merging entries which could had just been a single anime entry based on name matching and a configurable merge window.</div></div> + <div class="fieldDescription checkboxFieldDescription">Blur the boundaries between AniDB anime further by merging entries which could have just been a single anime entry based on name matching and a configurable merge window.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1402,35 +1394,35 @@ <h3>Basic Settings</h3> <input is="emby-checkbox" type="checkbox" id="AddTrailers" /> <span>Add Trailers</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add trailers to entities within the VFS. Trailers within the trailers directory when not using the VFS are not affected by this option.</div> + <div class="fieldDescription checkboxFieldDescription">Add trailers to entities within the VFS.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddCreditsAsThemeVideos" /> <span>Add Credits as Theme Videos</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add all credits as theme videos to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> + <div class="fieldDescription checkboxFieldDescription">Add all credits as theme videos to entities with in the VFS.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="AddCreditsAsSpecialFeatures" /> <span>Add Credits as Special Features</span> </label> - <div class="fieldDescription checkboxFieldDescription">Add all credits as special features to entities with in the VFS. In a non-VFS library they will just be filtered out since we can't properly support them as Jellyfin native features.</div> + <div class="fieldDescription checkboxFieldDescription">Add all credits as special features to entities with in the VFS.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_AddReleaseGroup" /> <span>Add Release Group to Path</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the full or short release group name to all automatically linked files in the VFS, and "No Group" for all manually linked files in the VFS. <strong>Warning</strong>: The release group in the file name may change if the release group info is incomplete, unavailable, or otherwise updated in Shoko at a later date, and thus may cause episode/movie entries to be "removed" and "added" as new entries when that happens. <strong>Use at your own risk.</strong></div> + <div class="fieldDescription checkboxFieldDescription">Add the full or short release group name to all automatically linked files in the VFS. "No Group" will be used for all manually linked files. <strong>Warning</strong>: The release group in the file name may change if the release group info is incomplete, unavailable, or otherwise updated in Shoko at a later date, and thus may cause episode/movie entries to be "removed" and "added" as new entries when that happens. <strong>Use at your own risk.</strong></div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_AddResolution" /> <span>Add Resolution to Path</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will add the standardized resolution (e.g. 480p, 1080p, 4K, etc.) to all files in the VFS, <strong>IF</strong> it's available. <strong>Warning</strong>: Though rare, we may have failed to read the media info in Shoko when the files were first added (e.g. because of a corrupt file, encountering an unsupported <i>new</i> codec, etc.), then reading it later. This may lead to episode/movie entries to be "removed" and "added" as new entries the next time they are refreshed after the metadata has been added. <strong>Use at your own risk.</strong></div> + <div class="fieldDescription checkboxFieldDescription">Enabling this will add the standardized resolution (e.g. 480p, 1080p, 4K, etc.) to all files in the VFS if available. <strong>Warning</strong>: Though rare, we may fail to read the media info in Shoko when the files were first added (e.g. because of a corrupt file, encountering an unsupported <i>new</i> codec, etc.), then reading it later. This may lead to episode/movie entries to be "removed" and "added" as new entries the next time they are refreshed after the metadata has been added. <strong>Use at your own risk.</strong></div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1446,29 +1438,29 @@ <h3>VFS Location Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_ResolveLinks" /> - <span>Resolve Links before VFS</span> + <span>Resolve Links Before VFS</span> </label> <div class="fieldDescription checkboxFieldDescription">If the library contains symbolic links to media, it will follow them until a final "real" file is found and use the path of said file for the VFS.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription expert-only"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="VFS_AttachRoot" /> - <span>Physically Attach VFS to Libraries</span> + <span>Attach VFS to Libraries</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this will attach a physical VFS "root" to your libraries as a media folder, allowing us to mount the VFS children to the new "root" and to re-use media folders across libraries without resorting to <i>other</i> workarounds.</div> + <div class="fieldDescription checkboxFieldDescription">Makes the VFS structure that the plugin automatically creates show up as a folder for your libraries. This allows reusing the same media folder(s) across multiple libraries without needing any other workarounds. Useful for separating movies and series into separate libraries while still using the same media folder(s) for both.</div> </div> - <div id="VFS_LocationContainer" class="selectContainer selectContainer-withDescription expert-only"> + <div class="selectContainer selectContainer-withDescription expert-only"> <label class="selectLabel" for="VFS_Location">VFS Location</label> <select is="emby-select" id="VFS_Location" name="VFS_Location" class="emby-select-withcolor emby-select"> <option value="Default" selected>Jellyfin Data Directory (Default)</option> <option value="Cache">Jellyfin Cache Directory</option> <option value="Custom">Custom Directory</option> </select> - <div class="fieldDescription">Determines where the VFS root will be placed. Changing this will cause your library to "remove" and "re-add" itself because of the path changes. You will need to manually move your VFS root if you plan to keep it when toggling this setting, or you can get rid of it by running the scheduled task to remove it. <strong>You have been warned.</strong></div> + <div class="fieldDescription">Change where the VFS structure will be placed. Changing this will cause your library to "remove" and "re-add" itself because of the path changes. You will need to manually move your VFS root if you plan to keep it when toggling this setting. Trick-play files will need to be backed-up beforehand and moved back the next library scan if you want to avoid regenerating them after you change this setting. <strong>You have been warned.</strong></div> </div> <div id="VFS_CustomLocationContainer" class="inputContainer inputContainer-withDescription expert-only" hidden> <input is="emby-input" type="text" id="VFS_CustomLocation" label="Custom VFS Root Location" disabled /> - <div class="fieldDescription">An absolute path to the custom root directory of where the VFS will be placed. You decide.</div> + <div class="fieldDescription">An absolute path, or a relative path from the Jellyfin Data Directory, to the custom root directory of where the VFS will be placed. You decide.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1486,13 +1478,13 @@ <h3>Connection Status</h3> <button id="SignalRConnectButton" is="emby-button" type="submit" name="signalr-connect" class="raised button-submit block emby-button" disabled> <span>Connect</span> </button> - <div class="fieldDescription">Establish a SignalR connection to Shoko Server.</div> + <div class="fieldDescription">Establish a SignalR connection to Shoko.</div> </div> <div id="SignalRDisconnectContainer"> <button is="emby-button" type="submit" name="signalr-disconnect" class="raised block emby-button"> <span>Disconnect</span> </button> - <div class="fieldDescription">Terminate the SignalR connection to Shoko Server.</div> + <div class="fieldDescription">Terminate the SignalR connection to Shoko.</div> </div> </fieldset> <fieldset id="SignalR_Basic_Section" class="verticalSection verticalSection-extrabottompadding" hidden> @@ -1502,15 +1494,15 @@ <h3>Basic Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SignalRAutoConnect" /> - <span>Auto Connect On Start</span> + <span>Auto Connect on Start</span> </label> <div class="fieldDescription checkboxFieldDescription"> - Automatically establish a SignalR connection to Shoko Server when Jellyfin starts. + Attempt to establish a SignalR connection to your running Shoko instance when Jellyfin starts up. </div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> <input is="emby-input" type="text" id="SignalRAutoReconnectIntervals" label="Auto Reconnect Intervals" /> - <div class="fieldDescription">A comma separated list of intervals in seconds to try re-establish the connection if the plugin gets disconnected from Shoko Server.</div> + <div class="fieldDescription">A comma separated list of intervals given in seconds to try to reconnect to your running Shoko instance if we ever gets disconnected. The provided list will have duplicates removed and will be performed in sorted order starting from the shortest time span when reconnecting. Once the longest interval has been reached and fails to reconnect, we will stop attempting to reconnect and leave the SignalR connection in a disconnected state until it is manually or otherwise reconnected.</div> </div> <div id="SignalREventSources" is="checkbox-list" class="expert-only" style="margin-bottom: 2em;"> <h3 class="checkboxListLabel">Event Sources</h3> @@ -1543,7 +1535,7 @@ <h3 class="listItemBodyText">TMDB</h3> </div> </div> </div> - <div class="fieldDescription">Which event sources should be listened to via the SignalR connection.</div> + <div class="fieldDescription">Which event sources should be listened to for metadata events.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1559,7 +1551,7 @@ <h3>New Library Settings</h3> <span>File Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables SignalR file events and real-time monitoring events for any new libraries.</div> + <div>Listen for file events, such as when new media gets added or moved around in the file system, and trigger library updates accordingly. Especially useful if your Jellyfin library is using a network share for it's media folder(s) where native file events may not be available for Jellyfin's real time monitoring to function properly. Can be used as a complete replacement for Jellyfin's built-in real time monitoring or used alongside it.</div> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> @@ -1568,7 +1560,7 @@ <h3>New Library Settings</h3> <span>Metadata Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables SignalR metadata update events for any new libraries.</div> + <div>Listen for metadata update events, such as when metadata gets added, updated, or removed, and trigger library updates accordingly. Can be useful when metadata is partially titles or other info initially, but which were added or updated at a later time.</div> </div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> @@ -1593,7 +1585,7 @@ <h3>Existing Library Settings</h3> <span>File Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables SignalR file events and real-time monitoring events for the media folder.</div> + <div>Listen for file events, such as when new media gets added or moved around in the file system, and trigger library updates accordingly. Especially useful if your Jellyfin library is using a network share for it's media folder(s) where native file events may not be available for Jellyfin's real time monitoring to function properly. Can be used as a complete replacement for Jellyfin's built-in real time monitoring or used alongside it.</div> </div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> @@ -1602,7 +1594,7 @@ <h3>Existing Library Settings</h3> <span>Metadata Events</span> </label> <div class="fieldDescription checkboxFieldDescription"> - <div>Enables SignalR metadata update events for the media folder.</div> + <div>Listen for metadata update events, such as when metadata gets added, updated, or removed, and trigger library updates accordingly. Can be useful when metadata is partially titles or other info initially, but which were added or updated at a later time.</div> </div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> @@ -1625,59 +1617,59 @@ <h3>User Settings</h3> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="UserEnableSynchronization" /> - <span>Enable synchronization features</span> + <span>Enable Synchronization Features</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting is mandatory to enable the sync features below. Select the sync feature you would like to use by enabling the checkbox.</div> + <div class="fieldDescription checkboxFieldDescription">Enable syncing certain data between Jellyfin and Shoko for the user. The specific behavior on what gets synced and when can be configured using the remaining options in this section. Leaving this setting unchecked will disable synchronization entirely, regardless of other synchronization settings.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncUserDataOnImport" /> - <span>Sync watch-state on import or refresh</span> + <span>Sync Watch-State on Import or Refresh</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will import the watch-state for items from Shoko on import or refresh.</div> + <div class="fieldDescription checkboxFieldDescription">When media gets scanned into your library or when a library's metadata gets refreshed, sync watch-state between Jellyfin and Shoko.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncUserDataAfterPlayback" /> - <span>Sync watch-state after playback</span> + <span>Sync Watch-State After Playback</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko after playback has ended.</div> + <div class="fieldDescription checkboxFieldDescription">Sync watch-state with Shoko when you finish watching a movie, episode, or other video.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncUserDataUnderPlayback" /> - <span>Sync watch-state events during playback</span> + <span>Sync Watch-State Events During Playback</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko on every play/pause/resume/stop event during playback.</div> + <div class="fieldDescription checkboxFieldDescription">Sync watch-state to Shoko on play/pause/stop/resume events during media playback.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncUserDataUnderPlaybackLive" /> - <span>Sync watch-state live during playback</span> + <span>Sync Watch-State Live During Playback</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko live during playback.</div> + <div class="fieldDescription checkboxFieldDescription">Sync watch-state to Shoko at a regular interval during media playback.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncUserDataInitialSkipEventCount" /> - <span>Lazy sync watch-state events with shoko</span> + <span>Lazy Sync Watch-State Events With Shoko</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will add a safe buffer of 10 seconds of playback before the plugin will start the sync-back to shoko. This will prevent accidental clicks and/or previews from marking the file as watched in shoko, and will also keep them more in sync with jellyfin, since it's closer to how Jellyfin handles the watch-state internally.</div> + <div class="fieldDescription checkboxFieldDescription">Wait about 10 seconds before syncing any watch-state events to Shoko. This will prevent accidental clicks and/or previews from marking a file as watched in Shoko, and will also keep them more in sync with Jellyfin since it's closer to how Jellyfin handles the watch-state internally.</div> </div> <div class="checkboxContainer checkboxContainer-withDescription"> <label class="emby-checkbox-label"> <input is="emby-checkbox" type="checkbox" id="SyncRestrictedVideos" /> - <span>Sync watch-state for restricted videos</span> + <span>Sync Watch-State for Restricted Media</span> </label> - <div class="fieldDescription checkboxFieldDescription">Enabling this setting will sync-back the watch-state to Shoko for restricted videos (H).</div> + <div class="fieldDescription checkboxFieldDescription">Allow syncing watch-state for restricted media (H) to Shoko.</div> </div> <div class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="text" id="UserUsername" label="Username" /> - <div class="fieldDescription">The username of the account to synchronize with the currently selected user.</div> + <div class="fieldDescription">The username of the account in Shoko to synchronize with the currently selected user.</div> </div> <div id="UserPasswordContainer" class="inputContainer inputContainer-withDescription"> <input is="emby-input" type="password" id="UserPassword" label="Password" /> - <div class="fieldDescription">The password for account. It can be empty.</div> + <div class="fieldDescription">The password of the account in Shoko to synchronize with the currently selected user.</div> </div> <div id="UserDeleteContainer" class="inputContainer inputContainer-withDescription" hidden> <button is="emby-button" type="submit" name="unlink-user" class="raised button-delete block emby-button"> @@ -1699,11 +1691,11 @@ <h3>Miscellaneous Settings</h3> <input is="emby-checkbox" type="checkbox" id="Misc_ShowInMenu" /> <span>Show in Menu</span> </label> - <div class="fieldDescription checkboxFieldDescription">Shows a shortcut to the plugin in the sidebar navigation menu.</div> + <div class="fieldDescription checkboxFieldDescription">Shows a shortcut to the plugin settings in the sidebar navigation menu.</div> </div> <div class="inputContainer inputContainer-withDescription expert-only"> - <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored folder names" /> - <div class="fieldDescription">A comma separated list of folder names which will be ignored during library filtering.</div> + <input is="emby-input" type="text" id="IgnoredFolders" label="Ignored Folder Names" /> + <div class="fieldDescription">A comma separated list of folder names to ignore during a library scan. Useful for skipping folders generated by a NAS or other pieces of software that access the file system where media resides. Only applicable to libraries not managed by the VFS as files not recognized by Shoko Server are already ignored.</div> </div> <button is="emby-button" type="submit" name="settings" class="raised button-submit block emby-button"> <span>Save</span> @@ -1714,7 +1706,7 @@ <h3>Miscellaneous Settings</h3> <h3>Utilities</h3> </legend> <div id="UserDeleteContainer" class="inputContainer inputContainer-withDescription expert-only"> - <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt block emby-button link-redirection" target="_blank" href="Shoko.Utilities.Dummy"> + <a is="emby-linkbutton" rel="noopener noreferrer" class="raised button-alt block emby-button link-redirection" target="_blank" href="#" data-page="Shoko.Utilities.Dummy"> <span>Dummy Example</span> </a> <div class="fieldDescription">Don't click the button above! You dummy!</div>